Skip to main content

vtcode_core/llm/providers/
copilot.rs

1use std::fmt::Write;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::Context;
6use async_stream::stream;
7use async_trait::async_trait;
8use tokio::sync::Mutex;
9use vtcode_config::auth::CopilotAuthConfig;
10use vtcode_config::constants::models::copilot as copilot_models;
11use vtcode_config::models::supported_models_for_provider;
12
13use crate::copilot::{
14    COPILOT_MODEL_ID, COPILOT_PROVIDER_KEY, CopilotAcpClient, CopilotPromptSessionFuture,
15    CopilotRuntimeRequest, CopilotToolCallFailure, CopilotToolCallResponse, PromptSession,
16    PromptSessionCancelHandle, PromptUpdate, probe_auth_status,
17};
18use crate::llm::provider::{
19    LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, Message,
20    MessageRole, ToolDefinition,
21};
22use crate::llm::providers::common::validate_request_common;
23
24pub struct CopilotProvider {
25    model: String,
26    auth_config: CopilotAuthConfig,
27    workspace_root: PathBuf,
28    client: Mutex<Option<CachedCopilotClient>>,
29}
30
31struct CachedCopilotClient {
32    raw_model: Option<String>,
33    tool_signature: String,
34    client: Arc<CopilotAcpClient>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct ResolvedCopilotModel {
39    request_model: String,
40    raw_model: Option<String>,
41}
42
43impl CopilotProvider {
44    pub fn from_config(
45        model: Option<String>,
46        auth_config: Option<CopilotAuthConfig>,
47        workspace_root: Option<PathBuf>,
48    ) -> Self {
49        Self {
50            model: model.unwrap_or_else(|| COPILOT_MODEL_ID.to_string()),
51            auth_config: auth_config.unwrap_or_default(),
52            workspace_root: workspace_root
53                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))),
54            client: Mutex::new(None),
55        }
56    }
57
58    async fn client(
59        &self,
60        model: &ResolvedCopilotModel,
61        tools: &[ToolDefinition],
62    ) -> Result<Arc<CopilotAcpClient>, LLMError> {
63        let tool_signature = copilot_tool_signature(tools);
64        if let Some(client) = self.cached_client(model, &tool_signature).await {
65            return Ok(client);
66        }
67
68        let auth_status = probe_auth_status(&self.auth_config, Some(&self.workspace_root)).await;
69        if !auth_status.is_authenticated() {
70            return Err(LLMError::Authentication {
71                message: auth_status.message.unwrap_or_else(|| {
72                    "GitHub Copilot is not authenticated. Run `vtcode login copilot`.".to_string()
73                }),
74                metadata: None,
75            });
76        }
77
78        let created = Arc::new(
79            CopilotAcpClient::connect(
80                &self.auth_config,
81                &self.workspace_root,
82                model.raw_model.as_deref(),
83                tools,
84            )
85            .await
86            .map_err(map_copilot_error)?,
87        );
88
89        let mut client = self.client.lock().await;
90        if let Some(existing) = client.as_ref()
91            && existing.raw_model.as_deref() == model.raw_model.as_deref()
92            && existing.tool_signature == tool_signature
93        {
94            return Ok(existing.client.clone());
95        }
96        *client = Some(CachedCopilotClient {
97            raw_model: model.raw_model.clone(),
98            tool_signature,
99            client: created.clone(),
100        });
101        Ok(created)
102    }
103
104    async fn cached_client(
105        &self,
106        model: &ResolvedCopilotModel,
107        tool_signature: &str,
108    ) -> Option<Arc<CopilotAcpClient>> {
109        let client = self.client.lock().await;
110        client
111            .as_ref()
112            .filter(|cached| {
113                cached.raw_model.as_deref() == model.raw_model.as_deref()
114                    && cached.tool_signature == tool_signature
115            })
116            .map(|cached| cached.client.clone())
117    }
118
119    fn resolve_model(&self, request: &LLMRequest) -> Result<ResolvedCopilotModel, LLMError> {
120        let requested = if request.model.trim().is_empty() {
121            self.model.trim()
122        } else {
123            request.model.trim()
124        };
125
126        let raw_model = normalize_copilot_model_id(requested).ok_or_else(|| {
127            invalid_request(&format!(
128                "Unsupported GitHub Copilot model: {requested}. Choose `copilot-auto` or a live GitHub Copilot model id from the picker."
129            ))
130        })?;
131
132        Ok(ResolvedCopilotModel {
133            request_model: requested.to_string(),
134            raw_model,
135        })
136    }
137
138    fn build_transcript(&self, request: &LLMRequest) -> Result<String, LLMError> {
139        let mut transcript = String::new();
140
141        if let Some(system_prompt) = request.system_prompt.as_ref() {
142            append_block(&mut transcript, "System", system_prompt);
143        }
144
145        for message in &request.messages {
146            let label = match message.role {
147                MessageRole::System => "System",
148                MessageRole::User => "User",
149                MessageRole::Assistant => "Assistant",
150                MessageRole::Tool => "Tool",
151            };
152            append_block(&mut transcript, label, &render_message_for_copilot(message));
153        }
154
155        Ok(transcript)
156    }
157
158    async fn stream_from_session(
159        &self,
160        model: ResolvedCopilotModel,
161        prompt_session: PromptSession,
162    ) -> Result<LLMStream, LLMError> {
163        struct PromptCancellationGuard {
164            cancel_handle: Option<PromptSessionCancelHandle>,
165        }
166
167        impl PromptCancellationGuard {
168            fn new(cancel_handle: PromptSessionCancelHandle) -> Self {
169                Self {
170                    cancel_handle: Some(cancel_handle),
171                }
172            }
173
174            fn disarm(&mut self) {
175                self.cancel_handle = None;
176            }
177        }
178
179        impl Drop for PromptCancellationGuard {
180            fn drop(&mut self) {
181                if let Some(cancel_handle) = self.cancel_handle.take() {
182                    cancel_handle.cancel();
183                }
184            }
185        }
186
187        let (mut updates, mut runtime_requests, completion, cancel_handle) =
188            prompt_session.into_parts();
189        let stream = stream! {
190            let mut cancellation_guard = PromptCancellationGuard::new(cancel_handle);
191            let completion = completion;
192            tokio::pin!(completion);
193
194            let mut content = String::new();
195            let mut reasoning = String::new();
196
197            loop {
198                tokio::select! {
199                    update = updates.recv() => {
200                        match update {
201                            Some(PromptUpdate::Text(delta)) => {
202                                content.push_str(&delta);
203                                yield Ok(LLMStreamEvent::Token { delta });
204                            }
205                            Some(PromptUpdate::Thought(delta)) => {
206                                let delta = if !reasoning.is_empty()
207                                    && !reasoning.ends_with('\n')
208                                    && !delta.starts_with('\n')
209                                {
210                                    format!("\n{delta}")
211                                } else {
212                                    delta
213                                };
214                                reasoning.push_str(&delta);
215                                yield Ok(LLMStreamEvent::Reasoning { delta });
216                            }
217                            None => {}
218                        }
219                    }
220                    runtime_request = runtime_requests.recv() => {
221                        if let Some(runtime_request) = runtime_request {
222                            let response = match runtime_request {
223                                CopilotRuntimeRequest::Permission(request) => {
224                                    request.respond(crate::copilot::CopilotPermissionDecision::DeniedNoApprovalRule)
225                                }
226                                CopilotRuntimeRequest::ToolCall(request) => {
227                                    let tool_name = request.request.tool_name.clone();
228                                    request.respond(CopilotToolCallResponse::Failure(CopilotToolCallFailure {
229                                        text_result_for_llm: format!(
230                                            "GitHub Copilot tool execution is not available in this session mode. Tool `{tool_name}` was not executed."
231                                        ),
232                                        error: format!(
233                                            "tool '{tool_name}' cannot be executed outside the VT Code agent runloop session"
234                                        ),
235                                    }))
236                                }
237                                CopilotRuntimeRequest::TerminalCreate(_)
238                                | CopilotRuntimeRequest::TerminalOutput(_)
239                                | CopilotRuntimeRequest::TerminalRelease(_)
240                                | CopilotRuntimeRequest::TerminalKill(_)
241                                | CopilotRuntimeRequest::TerminalWaitForExit(_) => {
242                                    continue;
243                                }
244                                CopilotRuntimeRequest::ObservedToolCall(_) => {
245                                    continue;
246                                }
247                                CopilotRuntimeRequest::CompatibilityNotice(_) => {
248                                    continue;
249                                }
250                            };
251                            if let Err(err) = response {
252                                yield Err(map_copilot_error(err));
253                                break;
254                            }
255                        }
256                    }
257                    result = &mut completion => {
258                        let completion = match result.context("copilot acp prompt task join failed") {
259                            Ok(completion) => completion,
260                            Err(err) => {
261                                yield Err(map_copilot_error(err));
262                                break;
263                            }
264                        };
265                        let completion = match completion {
266                            Ok(completion) => completion,
267                            Err(err) => {
268                                yield Err(map_copilot_error(err));
269                                break;
270                            }
271                        };
272                        let finish_reason = map_stop_reason(&completion.stop_reason);
273                        while let Ok(update) = updates.try_recv() {
274                            match update {
275                                PromptUpdate::Text(delta) => {
276                                    content.push_str(&delta);
277                                    yield Ok(LLMStreamEvent::Token { delta });
278                                }
279                                PromptUpdate::Thought(delta) => {
280                                    let delta = if !reasoning.is_empty()
281                                        && !reasoning.ends_with('\n')
282                                        && !delta.starts_with('\n')
283                                    {
284                                        format!("\n{delta}")
285                                    } else {
286                                        delta
287                                    };
288                                    reasoning.push_str(&delta);
289                                    yield Ok(LLMStreamEvent::Reasoning { delta });
290                                }
291                            }
292                        }
293
294                        let mut response =
295                            LLMResponse::new(model.request_model.clone(), content.clone());
296                        response.finish_reason = finish_reason;
297                        if !reasoning.is_empty() {
298                            response.reasoning = Some(reasoning.clone());
299                        }
300                        cancellation_guard.disarm();
301                        yield Ok(LLMStreamEvent::Completed {
302                            response: Box::new(response),
303                        });
304                        break;
305                    }
306                }
307            }
308        };
309
310        Ok(Box::pin(stream))
311    }
312
313    async fn start_prompt_session_impl(
314        &self,
315        request: LLMRequest,
316        tools: &[ToolDefinition],
317    ) -> Result<PromptSession, LLMError> {
318        self.validate_request(&request)?;
319        let model = self.resolve_model(&request)?;
320        let transcript = self.build_transcript(&request)?;
321        let client = self.client(&model, tools).await?;
322        client
323            .start_prompt(transcript)
324            .await
325            .map_err(map_copilot_error)
326    }
327}
328
329#[async_trait]
330impl LLMProvider for CopilotProvider {
331    fn name(&self) -> &str {
332        COPILOT_PROVIDER_KEY
333    }
334
335    fn supports_streaming(&self) -> bool {
336        true
337    }
338
339    fn supports_non_streaming(&self, _model: &str) -> bool {
340        false
341    }
342
343    fn supports_reasoning(&self, _model: &str) -> bool {
344        true
345    }
346
347    fn supports_tools(&self, _model: &str) -> bool {
348        true
349    }
350
351    fn supports_structured_output(&self, _model: &str) -> bool {
352        false
353    }
354
355    fn supports_vision(&self, _model: &str) -> bool {
356        false
357    }
358
359    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
360        let model = self.resolve_model(&request)?;
361        let mut stream = self.stream(request).await?;
362        let mut content = String::new();
363        let mut reasoning = String::new();
364        let mut completed = None;
365
366        use futures::StreamExt;
367        while let Some(event) = stream.next().await {
368            match event? {
369                LLMStreamEvent::Token { delta } => content.push_str(&delta),
370                LLMStreamEvent::Reasoning { delta } => reasoning.push_str(&delta),
371                LLMStreamEvent::ReasoningSignature { .. } => {}
372                LLMStreamEvent::ReasoningStage { .. } => {}
373                LLMStreamEvent::Completed { response } => {
374                    completed = Some(*response);
375                    break;
376                }
377            }
378        }
379
380        Ok(completed.unwrap_or_else(|| {
381            let mut response = LLMResponse::new(model.request_model.clone(), content);
382            if !reasoning.is_empty() {
383                response.reasoning = Some(reasoning);
384            }
385            response
386        }))
387    }
388
389    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
390        self.validate_request(&request)?;
391        let model = self.resolve_model(&request)?;
392        let transcript = self.build_transcript(&request)?;
393        let client = self.client(&model, &[]).await?;
394        let prompt_session = client
395            .start_prompt(transcript)
396            .await
397            .map_err(map_copilot_error)?;
398        self.stream_from_session(model, prompt_session).await
399    }
400
401    fn start_copilot_prompt_session<'a>(
402        &'a self,
403        request: LLMRequest,
404        tools: &'a [ToolDefinition],
405    ) -> Option<CopilotPromptSessionFuture<'a>> {
406        Some(Box::pin(async move {
407            self.start_prompt_session_impl(request, tools).await
408        }))
409    }
410
411    fn supported_models(&self) -> Vec<String> {
412        supported_models_for_provider(COPILOT_PROVIDER_KEY)
413            .map(|models| models.iter().map(|model| (*model).to_string()).collect())
414            .unwrap_or_else(|| {
415                copilot_models::SUPPORTED_MODELS
416                    .iter()
417                    .map(|model| (*model).to_string())
418                    .collect()
419            })
420    }
421
422    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
423        validate_request_common(request, "GitHub Copilot", COPILOT_PROVIDER_KEY, None)?;
424
425        if request
426            .tools
427            .as_ref()
428            .is_some_and(|tools| !tools.is_empty())
429        {
430            return Err(invalid_request(
431                "GitHub Copilot in VT Code v1 does not accept VT Code tool definitions.",
432            ));
433        }
434
435        if request.output_format.is_some() {
436            return Err(invalid_request(
437                "GitHub Copilot in VT Code v1 does not support structured output.",
438            ));
439        }
440
441        Ok(())
442    }
443}
444
445fn append_block(buffer: &mut String, label: &str, text: &str) {
446    if text.trim().is_empty() {
447        return;
448    }
449    if !buffer.is_empty() {
450        buffer.push_str("\n\n");
451    }
452    buffer.push_str(label);
453    buffer.push_str(":\n");
454    buffer.push_str(text.trim());
455}
456
457fn render_message_for_copilot(message: &Message) -> String {
458    let mut sections = Vec::new();
459    let text = message.content.as_text();
460    let trimmed = text.trim();
461    if !trimmed.is_empty() {
462        sections.push(trimmed.to_string());
463    }
464
465    if let Some(tool_calls) = message
466        .tool_calls
467        .as_ref()
468        .filter(|calls| !calls.is_empty())
469    {
470        let mut tool_history = String::from("[VT Code tool call history]");
471        for call in tool_calls {
472            let (tool_name, args) = call
473                .function
474                .as_ref()
475                .map(|function| (function.name.as_str(), function.arguments.trim()))
476                .unwrap_or((call.call_type.as_str(), ""));
477            if args.is_empty() {
478                let _ = write!(tool_history, "\n- {tool_name} id={}", call.id);
479            } else {
480                let _ = write!(tool_history, "\n- {tool_name} id={} args={args}", call.id);
481            }
482        }
483        sections.push(tool_history);
484    }
485
486    if message.role == MessageRole::Tool {
487        let mut tool_result = String::from("[VT Code tool result]");
488        if let Some(tool_call_id) = message.tool_call_id.as_deref() {
489            let _ = write!(tool_result, "\n- tool_call_id: {tool_call_id}");
490        }
491        if let Some(origin_tool) = message.origin_tool.as_deref() {
492            let _ = write!(tool_result, "\n- tool: {origin_tool}");
493        }
494        sections.insert(0, tool_result);
495    }
496
497    let (image_count, file_count) = count_non_text_parts(message);
498    if image_count > 0 {
499        sections.push(format!(
500            "[VT Code omitted {image_count} image input{} because GitHub Copilot v1 only accepts text input.]",
501            plural_suffix(image_count)
502        ));
503    }
504    if file_count > 0 {
505        sections.push(format!(
506            "[VT Code omitted {file_count} file attachment{} because GitHub Copilot v1 only accepts text input.]",
507            plural_suffix(file_count)
508        ));
509    }
510
511    sections.join("\n\n")
512}
513
514fn count_non_text_parts(message: &Message) -> (usize, usize) {
515    match &message.content {
516        crate::llm::provider::MessageContent::Text(_) => (0, 0),
517        crate::llm::provider::MessageContent::Parts(parts) => {
518            let image_count = parts.iter().filter(|part| part.is_image()).count();
519            let file_count = parts.iter().filter(|part| part.is_file()).count();
520            (image_count, file_count)
521        }
522    }
523}
524
525fn plural_suffix(count: usize) -> &'static str {
526    if count == 1 { "" } else { "s" }
527}
528
529fn invalid_request(message: &str) -> LLMError {
530    LLMError::InvalidRequest {
531        message: message.to_string(),
532        metadata: None,
533    }
534}
535
536fn map_copilot_error(error: anyhow::Error) -> LLMError {
537    let message = error.to_string();
538    if message.contains("rpc error -32001") || message.contains("Authentication required") {
539        return LLMError::Authentication {
540            message: "GitHub Copilot authentication is required. Run `vtcode login copilot`."
541                .to_string(),
542            metadata: None,
543        };
544    }
545
546    LLMError::Provider {
547        message,
548        metadata: None,
549    }
550}
551
552fn map_stop_reason(stop_reason: &str) -> crate::llm::provider::FinishReason {
553    match stop_reason {
554        "end_turn" => crate::llm::provider::FinishReason::Stop,
555        "max_tokens" => crate::llm::provider::FinishReason::Length,
556        "refusal" => crate::llm::provider::FinishReason::Refusal,
557        "cancelled" => crate::llm::provider::FinishReason::Error("cancelled".to_string()),
558        other => crate::llm::provider::FinishReason::Error(other.to_string()),
559    }
560}
561
562fn copilot_tool_signature(tools: &[ToolDefinition]) -> String {
563    let mut signature_parts = tools
564        .iter()
565        .filter_map(|tool| {
566            let function = tool.function.as_ref()?;
567            Some(format!(
568                "{}:{}",
569                function.name,
570                serde_json::to_string(&function.parameters).ok()?
571            ))
572        })
573        .collect::<Vec<_>>();
574    signature_parts.sort_unstable();
575    signature_parts.join("|")
576}
577
578fn normalize_copilot_model_id(model: &str) -> Option<Option<String>> {
579    let trimmed = model.trim();
580    if trimmed.is_empty() {
581        return None;
582    }
583
584    match trimmed {
585        copilot_models::AUTO => Some(None),
586        copilot_models::GPT_5_2_CODEX => Some(Some("gpt-5.2-codex".to_string())),
587        copilot_models::GPT_5_1_CODEX_MAX => Some(Some("gpt-5.1-codex-max".to_string())),
588        copilot_models::GPT_5_4 => Some(Some("gpt-5.4".to_string())),
589        copilot_models::GPT_5_4_MINI => Some(Some("gpt-5.4-mini".to_string())),
590        copilot_models::CLAUDE_SONNET_4_6 => Some(Some("claude-sonnet-4.6".to_string())),
591        _ if trimmed.contains(char::is_whitespace) => None,
592        _ => Some(Some(trimmed.to_string())),
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::CopilotProvider;
599    use super::normalize_copilot_model_id;
600    use crate::llm::provider::{ContentPart, LLMProvider, LLMRequest, Message, ToolCall};
601    use std::path::PathBuf;
602    use std::sync::Arc;
603    use vtcode_config::constants::models::copilot as copilot_models;
604
605    fn provider() -> CopilotProvider {
606        CopilotProvider::from_config(None, None, Some(PathBuf::from("/tmp")))
607    }
608
609    #[test]
610    fn transcript_flattens_system_user_and_assistant_messages() {
611        let provider = provider();
612        let request = LLMRequest {
613            system_prompt: Some(Arc::new("Follow repository conventions.".to_string())),
614            messages: vec![
615                Message::user("Inspect the diff.".to_string()),
616                Message::assistant("The diff looks safe.".to_string()),
617            ],
618            ..Default::default()
619        };
620
621        let transcript = provider
622            .build_transcript(&request)
623            .expect("transcript should build");
624
625        assert_eq!(
626            transcript,
627            "System:\nFollow repository conventions.\n\nUser:\nInspect the diff.\n\nAssistant:\nThe diff looks safe."
628        );
629    }
630
631    #[test]
632    fn curated_model_mapping_uses_auto_as_empty_override() {
633        assert_eq!(normalize_copilot_model_id(copilot_models::AUTO), Some(None));
634        assert_eq!(
635            normalize_copilot_model_id(copilot_models::GPT_5_4),
636            Some(Some("gpt-5.4".to_string()))
637        );
638    }
639
640    #[test]
641    fn normalize_copilot_model_id_accepts_raw_model_ids() {
642        assert_eq!(
643            normalize_copilot_model_id("gpt-5.3-codex"),
644            Some(Some("gpt-5.3-codex".to_string()))
645        );
646        assert_eq!(normalize_copilot_model_id("gpt 5.3"), None);
647    }
648
649    #[test]
650    fn validate_request_allows_tool_history_followups() {
651        let provider = provider();
652        let request = LLMRequest {
653            messages: vec![Message::tool_response(
654                "call-1".to_string(),
655                "tool output".to_string(),
656            )],
657            ..Default::default()
658        };
659
660        provider
661            .validate_request(&request)
662            .expect("tool history should be flattened for Copilot");
663    }
664
665    #[test]
666    fn transcript_flattens_tool_history_and_image_inputs() {
667        let provider = provider();
668        let request = LLMRequest {
669            messages: vec![
670                Message::assistant_with_tools(
671                    "Running checks.".to_string(),
672                    vec![ToolCall::function(
673                        "call-1".to_string(),
674                        "unified_exec".to_string(),
675                        r#"{"cmd":"cargo check"}"#.to_string(),
676                    )],
677                ),
678                Message::tool_response_with_origin(
679                    "call-1".to_string(),
680                    "cargo check completed successfully.".to_string(),
681                    "unified_exec".to_string(),
682                ),
683                Message::user_with_parts(vec![
684                    ContentPart::text("Tell me more.".to_string()),
685                    ContentPart::image("AAAA".to_string(), "image/png".to_string()),
686                ]),
687            ],
688            ..Default::default()
689        };
690
691        let transcript = provider
692            .build_transcript(&request)
693            .expect("transcript should flatten Copilot-incompatible history");
694
695        assert!(transcript.contains("Assistant:\nRunning checks."));
696        assert!(transcript.contains("[VT Code tool call history]"));
697        assert!(transcript.contains("- unified_exec id=call-1 args={\"cmd\":\"cargo check\"}"));
698        assert!(transcript.contains("Tool:\n[VT Code tool result]"));
699        assert!(transcript.contains("- tool_call_id: call-1"));
700        assert!(transcript.contains("- tool: unified_exec"));
701        assert!(transcript.contains("cargo check completed successfully."));
702        assert!(transcript.contains("User:\nTell me more."));
703        assert!(transcript.contains("omitted 1 image input"));
704    }
705
706    #[test]
707    fn supported_models_include_copilot_auto() {
708        let provider = provider();
709
710        assert!(
711            provider
712                .supported_models()
713                .iter()
714                .any(|model| model == copilot_models::AUTO)
715        );
716    }
717
718    #[test]
719    fn supports_reasoning_for_alias_and_live_raw_models() {
720        let provider = provider();
721
722        assert!(provider.supports_reasoning(copilot_models::AUTO));
723        assert!(provider.supports_reasoning("gpt-5.3-codex"));
724    }
725}