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}