use std::pin::Pin; use std::str::FromStr as _; use std::sync::Arc; use anthropic::AnthropicModelMode; use anyhow::{Result, anyhow}; use collections::HashMap; use copilot::{GlobalCopilotAuth, Status}; use copilot_chat::responses as copilot_responses; use copilot_chat::{ ChatLocation, ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, CopilotChatConfiguration, Function, FunctionContent, ImageUrl, Model as CopilotChatModel, ModelVendor, Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent, ToolChoice, }; use futures::future::BoxFuture; use futures::stream::BoxStream; use futures::{FutureExt, Stream, StreamExt}; use gpui::{App, AsyncApp, Entity, Subscription, Task}; use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ AuthenticateError, CompletionIntent, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, }; use settings::SettingsStore; use ui::prelude::*; use util::debug_panic; use crate::provider::anthropic::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}; use language_model::util::{fix_streamed_json, parse_tool_arguments}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("GitHub Copilot Chat"); pub struct CopilotChatLanguageModelProvider { state: Entity, } pub struct State { _copilot_chat_subscription: Option, _settings_subscription: Subscription, } impl State { fn is_authenticated(&self, cx: &App) -> bool { CopilotChat::global(cx) .map(|m| m.read(cx).is_authenticated()) .unwrap_or(false) } } impl CopilotChatLanguageModelProvider { pub fn new(cx: &mut App) -> Self { let state = cx.new(|cx| { let copilot_chat_subscription = CopilotChat::global(cx) .map(|copilot_chat| cx.observe(&copilot_chat, |_, _, cx| cx.notify())); State { _copilot_chat_subscription: copilot_chat_subscription, _settings_subscription: cx.observe_global::(|_, cx| { if let Some(copilot_chat) = CopilotChat::global(cx) { let language_settings = all_language_settings(None, cx); let configuration = CopilotChatConfiguration { enterprise_uri: language_settings .edit_predictions .copilot .enterprise_uri .clone(), }; copilot_chat.update(cx, |chat, cx| { chat.set_configuration(configuration, cx); }); } cx.notify(); }), } }); Self { state } } fn create_language_model(&self, model: CopilotChatModel) -> Arc { Arc::new(CopilotChatLanguageModel { model, request_limiter: RateLimiter::new(4), }) } } impl LanguageModelProviderState for CopilotChatLanguageModelProvider { type ObservableEntity = State; fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } impl LanguageModelProvider for CopilotChatLanguageModelProvider { fn id(&self) -> LanguageModelProviderId { PROVIDER_ID } fn name(&self) -> LanguageModelProviderName { PROVIDER_NAME } fn icon(&self) -> IconOrSvg { IconOrSvg::Icon(IconName::Copilot) } fn default_model(&self, cx: &App) -> Option> { let models = CopilotChat::global(cx).and_then(|m| m.read(cx).models())?; models .first() .map(|model| self.create_language_model(model.clone())) } fn default_fast_model(&self, cx: &App) -> Option> { // The default model should be Copilot Chat's 'base model', which is likely a relatively fast // model (e.g. 4o) and a sensible choice when considering premium requests self.default_model(cx) } fn provided_models(&self, cx: &App) -> Vec> { let Some(models) = CopilotChat::global(cx).and_then(|m| m.read(cx).models()) else { return Vec::new(); }; models .iter() .map(|model| self.create_language_model(model.clone())) .collect() } fn is_authenticated(&self, cx: &App) -> bool { self.state.read(cx).is_authenticated(cx) } fn authenticate(&self, cx: &mut App) -> Task> { if self.is_authenticated(cx) { return Task::ready(Ok(())); }; let Some(copilot) = GlobalCopilotAuth::try_global(cx).cloned() else { return Task::ready(Err(anyhow!(concat!( "Copilot must be enabled for Copilot Chat to work. ", "Please enable Copilot and try again." )) .into())); }; let err = match copilot.0.read(cx).status() { Status::Authorized => return Task::ready(Ok(())), Status::Disabled => anyhow!( "Copilot must be enabled for Copilot Chat to work. Please enable Copilot and try again." ), Status::Error(err) => anyhow!(format!( "Received the following error while signing into Copilot: {err}" )), Status::Starting { task: _ } => anyhow!( "Copilot is still starting, please wait for Copilot to start then try again" ), Status::Unauthorized => anyhow!( "Unable to authorize with Copilot. Please make sure that you have an active Copilot and Copilot Chat subscription." ), Status::SignedOut { .. } => { anyhow!("You have signed out of Copilot. Please sign in to Copilot and try again.") } Status::SigningIn { prompt: _ } => anyhow!("Still signing into Copilot..."), }; Task::ready(Err(err.into())) } fn settings_view(&self, cx: &mut App) -> Option { let is_authenticated = self.state.read(cx).is_authenticated(cx); let title = if is_authenticated { None } else { Some("Configure Copilot".into()) }; let description = if is_authenticated { None } else { Some(language_model::InlineDescription::Text( "Requires an active GitHub Copilot subscription.".into(), )) }; Some(ProviderSettingsView::Inline( language_model::InlineProviderSettings { title, description, create_view: Arc::new(|_window, cx| { cx.new(|cx| { copilot_ui::ConfigurationView::new( |cx| { CopilotChat::global(cx) .map(|m| m.read(cx).is_authenticated()) .unwrap_or(false) }, copilot_ui::ConfigurationMode::Chat, cx, ) .compact() }) .into() }), }, )) } } pub struct CopilotChatLanguageModel { model: CopilotChatModel, request_limiter: RateLimiter, } impl LanguageModel for CopilotChatLanguageModel { fn id(&self) -> LanguageModelId { LanguageModelId::from(self.model.id().to_string()) } fn name(&self) -> LanguageModelName { LanguageModelName::from(self.model.display_name().to_string()) } fn provider_id(&self) -> LanguageModelProviderId { PROVIDER_ID } fn provider_name(&self) -> LanguageModelProviderName { PROVIDER_NAME } fn supports_tools(&self) -> bool { self.model.supports_tools() } fn supports_streaming_tools(&self) -> bool { true } fn supports_images(&self) -> bool { self.model.supports_vision() } fn supports_thinking(&self) -> bool { self.model.can_think() } fn supported_effort_levels(&self) -> Vec { let levels = self.model.reasoning_effort_levels(); if levels.is_empty() { return vec![]; } levels .iter() .map(|level| { let name = match level.as_str() { "low" => "Low".into(), "medium" => "Medium".into(), "high" => "High".into(), "xhigh" => "Extra High".into(), _ => language_model::SharedString::from(level.clone()), }; LanguageModelEffortLevel { name, value: language_model::SharedString::from(level.clone()), is_default: level == "high", } }) .collect() } fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { match self.model.vendor() { ModelVendor::OpenAI | ModelVendor::Anthropic => { LanguageModelToolSchemaFormat::JsonSchema } ModelVendor::Google | ModelVendor::XAI | ModelVendor::Unknown => { LanguageModelToolSchemaFormat::JsonSchemaSubset } } } fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any | LanguageModelToolChoice::None => self.supports_tools(), } } fn model_cost_info(&self) -> Option { LanguageModelCostInfo::RequestCost { cost_per_request: self.model.multiplier(), } .into() } fn telemetry_id(&self) -> String { format!("copilot_chat/{}", self.model.id()) } fn max_token_count(&self) -> u64 { self.model.max_token_count() } fn stream_completion( &self, request: LanguageModelRequest, cx: &AsyncApp, ) -> BoxFuture< 'static, Result< BoxStream<'static, Result>, LanguageModelCompletionError, >, > { let is_user_initiated = request.intent.is_none_or(|intent| match intent { CompletionIntent::UserPrompt | CompletionIntent::ThreadContextSummarization | CompletionIntent::InlineAssist | CompletionIntent::TerminalInlineAssist | CompletionIntent::GenerateGitCommitMessage => true, CompletionIntent::Subagent | CompletionIntent::ToolResults | CompletionIntent::ThreadSummarization | CompletionIntent::CreateFile | CompletionIntent::EditFile => false, }); if self.model.supports_messages() { let location = intent_to_chat_location(request.intent); let model = self.model.clone(); let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { let effort = request .thinking_effort .as_ref() .and_then(|e| anthropic::Effort::from_str(e).ok()); let mut anthropic_request = into_anthropic( request, model.id().to_string(), 0.0, model.max_output_tokens() as u64, if model.supports_adaptive_thinking() { AnthropicModelMode::Thinking { budget_tokens: None, } } else if model.supports_thinking() { AnthropicModelMode::Thinking { budget_tokens: compute_thinking_budget( model.min_thinking_budget(), model.max_thinking_budget(), model.max_output_tokens() as u32, ), } } else { AnthropicModelMode::Default }, AnthropicPromptCacheMode::Legacy, &PROVIDER_ID, )?; anthropic_request.temperature = None; // The Copilot proxy doesn't support eager_input_streaming on tools. for tool in &mut anthropic_request.tools { tool.eager_input_streaming = false; } if model.supports_adaptive_thinking() { if anthropic_request.thinking.is_some() { anthropic_request.thinking = Some(anthropic::Thinking::Adaptive { display: Some(anthropic::AdaptiveThinkingDisplay::Summarized), }); anthropic_request.output_config = effort.map(|effort| anthropic::OutputConfig { effort: Some(effort), }); } } let anthropic_beta = if !model.supports_adaptive_thinking() && model.supports_thinking() { Some("interleaved-thinking-2025-05-14".to_string()) } else { None }; let body = serde_json::to_string(&anthropic::StreamingRequest { base: anthropic_request, stream: true, }) .map_err(|e| anyhow::anyhow!(e))?; let stream = CopilotChat::stream_messages( body, location, is_user_initiated, anthropic_beta, cx.clone(), ); request_limiter .stream(async move { let events = stream.await?; let mapper = AnthropicEventMapper::new(PROVIDER_NAME, PROVIDER_ID); Ok(mapper.map_stream(events).boxed()) }) .await }); return async move { Ok(future.await?.boxed()) }.boxed(); } if self.model.supports_response() { let location = intent_to_chat_location(request.intent); let responses_request = match into_copilot_responses(&self.model, request) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), }; let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { let request = CopilotChat::stream_response( responses_request, location, is_user_initiated, cx.clone(), ); request_limiter .stream(async move { let stream = request.await?; let mapper = CopilotResponsesEventMapper::new(); Ok(mapper.map_stream(stream).boxed()) }) .await }); return async move { Ok(future.await?.boxed()) }.boxed(); } let location = intent_to_chat_location(request.intent); let copilot_request = match into_copilot_chat(&self.model, request) { Ok(request) => request, Err(err) => return futures::future::ready(Err(err.into())).boxed(), }; let is_streaming = copilot_request.stream; let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { let request = CopilotChat::stream_completion( copilot_request, location, is_user_initiated, cx.clone(), ); request_limiter .stream(async move { let response = request.await?; Ok(map_to_language_model_completion_events( response, is_streaming, )) }) .await }); async move { Ok(future.await?.boxed()) }.boxed() } } pub fn map_to_language_model_completion_events( events: Pin>>>, is_streaming: bool, ) -> impl Stream> { #[derive(Default)] struct RawToolCall { id: String, name: String, arguments: String, thought_signature: Option, } struct State { events: Pin>>>, tool_calls_by_index: HashMap, reasoning_opaque: Option, reasoning_text: Option, } futures::stream::unfold( State { events, tool_calls_by_index: HashMap::default(), reasoning_opaque: None, reasoning_text: None, }, move |mut state| async move { if let Some(event) = state.events.next().await { match event { Ok(event) => { let Some(choice) = event.choices.first() else { return Some(( vec![Err(anyhow!("Response contained no choices").into())], state, )); }; let delta = if is_streaming { choice.delta.as_ref() } else { choice.message.as_ref() }; let Some(delta) = delta else { return Some(( vec![Err(anyhow!("Response contained no delta").into())], state, )); }; let mut events = Vec::new(); if let Some(content) = delta.content.clone() { events.push(Ok(LanguageModelCompletionEvent::Text(content))); } // Capture reasoning data from the delta (e.g. for Gemini 3) if let Some(opaque) = delta.reasoning_opaque.clone() { state.reasoning_opaque = Some(opaque); } if let Some(text) = delta.reasoning_text.clone() { state.reasoning_text = Some(text); } for (index, tool_call) in delta.tool_calls.iter().enumerate() { let tool_index = tool_call.index.unwrap_or(index); let entry = state.tool_calls_by_index.entry(tool_index).or_default(); if let Some(tool_id) = tool_call.id.clone() { entry.id = tool_id; } if let Some(function) = tool_call.function.as_ref() { if let Some(name) = function.name.clone() { entry.name = name; } if let Some(arguments) = function.arguments.clone() { entry.arguments.push_str(&arguments); } if let Some(thought_signature) = function.thought_signature.clone() { entry.thought_signature = Some(thought_signature); } } if !entry.id.is_empty() && !entry.name.is_empty() { if let Ok(input) = serde_json::from_str::( &fix_streamed_json(&entry.arguments), ) { events.push(Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, input: language_model::LanguageModelToolUseInput::Json( input, ), raw_input: entry.arguments.clone(), thought_signature: entry.thought_signature.clone(), }, ))); } } } if let Some(usage) = event.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate( TokenUsage { input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, ))); } match choice.finish_reason.as_deref() { Some("stop") => { events.push(Ok(LanguageModelCompletionEvent::Stop( StopReason::EndTurn, ))); } Some("tool_calls") => { // Gemini 3 models send reasoning_opaque/reasoning_text that must // be preserved and sent back in subsequent requests. Emit as // ReasoningDetails so the agent stores it in the message. if state.reasoning_opaque.is_some() || state.reasoning_text.is_some() { let mut details = serde_json::Map::new(); if let Some(opaque) = state.reasoning_opaque.take() { details.insert( "reasoning_opaque".to_string(), serde_json::Value::String(opaque), ); } if let Some(text) = state.reasoning_text.take() { details.insert( "reasoning_text".to_string(), serde_json::Value::String(text), ); } events.push(Ok( LanguageModelCompletionEvent::ReasoningDetails( serde_json::Value::Object(details), ), )); } events.extend(state.tool_calls_by_index.drain().map( |(_, tool_call)| match parse_tool_arguments( &tool_call.arguments, ) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.into(), name: tool_call.name.as_str().into(), is_input_complete: true, input: language_model::LanguageModelToolUseInput::Json( input, ), raw_input: tool_call.arguments, thought_signature: tool_call.thought_signature, }, )), Err(error) => Ok( LanguageModelCompletionEvent::ToolUseJsonParseError { id: tool_call.id.into(), tool_name: tool_call.name.as_str().into(), raw_input: tool_call.arguments.into(), json_parse_error: error.to_string(), }, ), }, )); events.push(Ok(LanguageModelCompletionEvent::Stop( StopReason::ToolUse, ))); } Some(stop_reason) => { log::error!("Unexpected Copilot Chat stop_reason: {stop_reason:?}"); events.push(Ok(LanguageModelCompletionEvent::Stop( StopReason::EndTurn, ))); } None => {} } return Some((events, state)); } Err(err) => return Some((vec![Err(anyhow!(err).into())], state)), } } None }, ) .flat_map(futures::stream::iter) } pub struct CopilotResponsesEventMapper { pending_stop_reason: Option, reasoning_items: Vec, } impl CopilotResponsesEventMapper { pub fn new() -> Self { Self { pending_stop_reason: None, reasoning_items: Vec::new(), } } pub fn map_stream( mut self, events: Pin>>>, ) -> impl Stream> { events.flat_map(move |event| { futures::stream::iter(match event { Ok(event) => self.map_event(event), Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))], }) }) } fn map_event( &mut self, event: copilot_responses::StreamEvent, ) -> Vec> { match event { copilot_responses::StreamEvent::OutputItemAdded { item, .. } => match item { copilot_responses::ResponseOutputItem::Message { id, .. } => { vec![Ok(LanguageModelCompletionEvent::StartMessage { message_id: id, })] } _ => Vec::new(), }, copilot_responses::StreamEvent::OutputTextDelta { delta, .. } => { if delta.is_empty() { Vec::new() } else { vec![Ok(LanguageModelCompletionEvent::Text(delta))] } } copilot_responses::StreamEvent::OutputItemDone { item, .. } => match item { copilot_responses::ResponseOutputItem::Message { .. } => Vec::new(), copilot_responses::ResponseOutputItem::FunctionCall { call_id, name, arguments, thought_signature, .. } => { let mut events = Vec::new(); match parse_tool_arguments(&arguments) { Ok(input) => events.push(Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: call_id.into(), name: name.as_str().into(), is_input_complete: true, input: language_model::LanguageModelToolUseInput::Json(input), raw_input: arguments.clone(), thought_signature, }, ))), Err(error) => { events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { id: call_id.into(), tool_name: name.as_str().into(), raw_input: arguments.clone().into(), json_parse_error: error.to_string(), })) } } // Record that we already emitted a tool-use stop so we can avoid duplicating // a Stop event on Completed. self.pending_stop_reason = Some(StopReason::ToolUse); events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); events } copilot_responses::ResponseOutputItem::Reasoning { id, summary, encrypted_content, } => { let mut events = Vec::new(); if let Some(blocks) = summary.as_ref() { let mut text = String::new(); for block in blocks { text.push_str(&block.text); } if !text.is_empty() { events.push(Ok(LanguageModelCompletionEvent::Thinking { text, signature: None, })); } } if let Some(reasoning_item) = reasoning_input_item_from_output(&id, encrypted_content) { events.extend(self.capture_reasoning_item(reasoning_item)); } events } }, copilot_responses::StreamEvent::Completed { response } => { let mut events = Vec::new(); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), output_tokens: usage.output_tokens.unwrap_or(0), cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }))); } if self.pending_stop_reason.take() != Some(StopReason::ToolUse) { events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); } events } copilot_responses::StreamEvent::Incomplete { response } => { let reason = response .incomplete_details .as_ref() .and_then(|details| details.reason.as_ref()); let stop_reason = match reason { Some(copilot_responses::IncompleteReason::MaxOutputTokens) => { StopReason::MaxTokens } Some(copilot_responses::IncompleteReason::ContentFilter) => StopReason::Refusal, _ => self .pending_stop_reason .take() .unwrap_or(StopReason::EndTurn), }; let mut events = Vec::new(); if let Some(usage) = response.usage { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: usage.input_tokens.unwrap_or(0), output_tokens: usage.output_tokens.unwrap_or(0), cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }))); } events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason))); events } copilot_responses::StreamEvent::Failed { response } => { let provider = PROVIDER_NAME; let (status_code, message) = match response.error { Some(error) => { let status_code = StatusCode::from_str(&error.code) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); (status_code, error.message) } None => ( StatusCode::INTERNAL_SERVER_ERROR, "response.failed".to_string(), ), }; vec![Err(LanguageModelCompletionError::HttpResponseError { provider, status_code, message, })] } copilot_responses::StreamEvent::GenericError { error } => vec![Err( LanguageModelCompletionError::Other(anyhow!(error.message)), )], copilot_responses::StreamEvent::Created { .. } | copilot_responses::StreamEvent::Unknown => Vec::new(), } } fn capture_reasoning_item( &mut self, reasoning_item: copilot_responses::ResponseReasoningInputItem, ) -> Vec> { if self.reasoning_items.contains(&reasoning_item) { return Vec::new(); } if let Some(id) = reasoning_item.id.as_ref() && let Some(existing_reasoning_item) = self .reasoning_items .iter_mut() .find(|existing_reasoning_item| existing_reasoning_item.id.as_ref() == Some(id)) { *existing_reasoning_item = reasoning_item; } else { self.reasoning_items.push(reasoning_item); } self.emit_response_message_metadata() } fn emit_response_message_metadata( &self, ) -> Vec> { let details = serde_json::to_value(CopilotResponseMessageMetadata { reasoning_items: self.reasoning_items.clone(), }); match details { Ok(details) => vec![Ok(LanguageModelCompletionEvent::ReasoningDetails(details))], Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))], } } } #[derive(serde::Serialize, serde::Deserialize)] struct CopilotResponseMessageMetadata { #[serde(default, skip_serializing_if = "Vec::is_empty")] reasoning_items: Vec, } fn append_reasoning_details_to_response_items( reasoning_details: Option<&serde_json::Value>, replayed_reasoning_item_indexes: &mut HashMap, input_items: &mut Vec, ) { let Some(reasoning_details) = reasoning_details else { return; }; let Some(metadata) = serde_json::from_value::(reasoning_details.clone()).ok() else { return; }; for mut reasoning_item in metadata.reasoning_items { reasoning_item.summary.clear(); if let Some(id) = reasoning_item.id.as_ref() { if let Some(index) = replayed_reasoning_item_indexes.get(id) { input_items[*index] = copilot_responses::ResponseInputItem::Reasoning(reasoning_item); return; } replayed_reasoning_item_indexes.insert(id.clone(), input_items.len()); } input_items.push(copilot_responses::ResponseInputItem::Reasoning( reasoning_item, )); } } fn reasoning_input_item_from_output( id: &str, encrypted_content: Option, ) -> Option { if encrypted_content.is_none() { return None; } Some(copilot_responses::ResponseReasoningInputItem { id: Some(id.to_string()), summary: Vec::new(), encrypted_content, }) } fn into_copilot_chat( model: &CopilotChatModel, request: LanguageModelRequest, ) -> Result { let temperature = request.temperature; let tool_choice = request.tool_choice; let thinking_allowed = request.thinking_allowed; let mut request_messages: Vec = Vec::new(); for message in request.messages { if let Some(last_message) = request_messages.last_mut() { if last_message.role == message.role { last_message.content.extend(message.content); } else { request_messages.push(message); } } else { request_messages.push(message); } } let mut messages: Vec = Vec::new(); for message in request_messages { match message.role { Role::User => { for content in &message.content { if let MessageContent::ToolResult(tool_result) = content { let parts: Vec = tool_result .content .iter() .map(|part| match part { LanguageModelToolResultContent::Text(text) => { ChatMessagePart::Text { text: text.to_string(), } } LanguageModelToolResultContent::Image(image) => { if model.supports_vision() { ChatMessagePart::Image { image_url: ImageUrl { url: image.to_base64_url(), }, } } else { debug_panic!( "This should be caught at {} level", tool_result.tool_name ); ChatMessagePart::Text { text: "[Tool responded with an image, but this model does not support vision]".to_string(), } } } }) .collect(); let content = match parts.as_slice() { [ChatMessagePart::Text { text }] => { ChatMessageContent::Plain(text.clone()) } _ => ChatMessageContent::Multipart(parts), }; messages.push(ChatMessage::Tool { tool_call_id: tool_result.tool_use_id.to_string(), content, }); } } let mut content_parts = Vec::new(); for content in &message.content { match content { MessageContent::Text(text) | MessageContent::Thinking { text, .. } if !text.is_empty() => { if let Some(ChatMessagePart::Text { text: text_content }) = content_parts.last_mut() { text_content.push_str(text); } else { content_parts.push(ChatMessagePart::Text { text: text.to_string(), }); } } MessageContent::Image(image) if model.supports_vision() => { content_parts.push(ChatMessagePart::Image { image_url: ImageUrl { url: image.to_base64_url(), }, }); } _ => {} } } if !content_parts.is_empty() { messages.push(ChatMessage::User { content: content_parts.into(), }); } } Role::Assistant => { let mut tool_calls = Vec::new(); for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { let input = tool_use.input.as_json().ok_or_else(|| { anyhow!("Copilot Chat does not support custom tool calls") })?; tool_calls.push(ToolCall { id: tool_use.id.to_string(), content: ToolCallContent::Function { function: FunctionContent { name: tool_use.name.to_string(), arguments: serde_json::to_string(input)?, thought_signature: tool_use.thought_signature.clone(), }, }, }); } } let text_content = { let mut buffer = String::new(); for string in message.content.iter().filter_map(|content| match content { MessageContent::Text(text) => Some(text.as_str()), MessageContent::Thinking { .. } | MessageContent::ToolUse(_) | MessageContent::RedactedThinking(_) | MessageContent::ToolResult(_) | MessageContent::Image(_) | MessageContent::Compaction(_) => None, }) { buffer.push_str(string); } buffer }; // Extract reasoning_opaque and reasoning_text from reasoning_details let (reasoning_opaque, reasoning_text) = if let Some(details) = &message.reasoning_details { let opaque = details .get("reasoning_opaque") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let text = details .get("reasoning_text") .and_then(|v| v.as_str()) .map(|s| s.to_string()); (opaque, text) } else { (None, None) }; messages.push(ChatMessage::Assistant { content: if text_content.is_empty() { ChatMessageContent::empty() } else { text_content.into() }, tool_calls, reasoning_opaque, reasoning_text, }); } Role::System => messages.push(ChatMessage::System { content: message.string_contents(), }), } } let tools = request .tools .iter() .map(|tool| match &tool.input { language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { Ok(Tool::Function { function: Function { name: tool.name.clone(), description: tool.description.clone(), parameters: input_schema.clone(), }, }) } language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!( "Copilot Chat does not support custom tools" )), }) .collect::>>()?; Ok(CopilotChatRequest { n: 1, stream: model.uses_streaming(), temperature: temperature.unwrap_or(0.1), model: model.id().to_string(), messages, tools, tool_choice: tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => ToolChoice::Auto, LanguageModelToolChoice::Any => ToolChoice::Required, LanguageModelToolChoice::None => ToolChoice::None, }), thinking_budget: if thinking_allowed && model.supports_thinking() { compute_thinking_budget( model.min_thinking_budget(), model.max_thinking_budget(), model.max_output_tokens() as u32, ) } else { None }, }) } fn compute_thinking_budget( min_budget: Option, max_budget: Option, max_output_tokens: u32, ) -> Option { let configured_budget: u32 = 16000; let min_budget = min_budget.unwrap_or(1024); let max_budget = max_budget.unwrap_or(max_output_tokens.saturating_sub(1)); let normalized = configured_budget.max(min_budget); Some( normalized .min(max_budget) .min(max_output_tokens.saturating_sub(1)), ) } fn intent_to_chat_location(intent: Option) -> ChatLocation { match intent { Some(CompletionIntent::UserPrompt) => ChatLocation::Agent, Some(CompletionIntent::Subagent) => ChatLocation::Agent, Some(CompletionIntent::ToolResults) => ChatLocation::Agent, Some(CompletionIntent::ThreadSummarization) => ChatLocation::Panel, Some(CompletionIntent::ThreadContextSummarization) => ChatLocation::Panel, Some(CompletionIntent::CreateFile) => ChatLocation::Agent, Some(CompletionIntent::EditFile) => ChatLocation::Agent, Some(CompletionIntent::InlineAssist) => ChatLocation::Editor, Some(CompletionIntent::TerminalInlineAssist) => ChatLocation::Terminal, Some(CompletionIntent::GenerateGitCommitMessage) => ChatLocation::Other, None => ChatLocation::Panel, } } fn into_copilot_responses( model: &CopilotChatModel, request: LanguageModelRequest, ) -> Result { use copilot_responses as responses; let LanguageModelRequest { thread_id: _, prompt_id: _, intent: _, messages, tools, tool_choice, stop: _, temperature, thinking_allowed, thinking_effort, speed: _, compact_at_tokens: _, } = request; let mut input_items: Vec = Vec::new(); let mut replayed_reasoning_item_indexes = HashMap::default(); for message in messages { match message.role { Role::User => { for content in &message.content { if let MessageContent::ToolResult(tool_result) = content { let output = match tool_result.content.as_slice() { [LanguageModelToolResultContent::Text(text)] => { responses::ResponseFunctionOutput::Text(text.to_string()) } _ => { let parts = tool_result .content .iter() .map(|part| match part { LanguageModelToolResultContent::Text(text) => { responses::ResponseInputContent::InputText { text: text.to_string(), } } LanguageModelToolResultContent::Image(image) => { if model.supports_vision() { responses::ResponseInputContent::InputImage { image_url: Some(image.to_base64_url()), detail: Default::default(), } } else { debug_panic!( "This should be caught at {} level", tool_result.tool_name ); responses::ResponseInputContent::InputText { text: "[Tool responded with an image, but this model does not support vision]".to_string(), } } } }) .collect(); responses::ResponseFunctionOutput::Content(parts) } }; input_items.push(responses::ResponseInputItem::FunctionCallOutput { call_id: tool_result.tool_use_id.to_string(), output, status: None, }); } } let mut parts: Vec = Vec::new(); for content in &message.content { match content { MessageContent::Text(text) => { parts.push(responses::ResponseInputContent::InputText { text: text.clone(), }); } MessageContent::Image(image) => { if model.supports_vision() { parts.push(responses::ResponseInputContent::InputImage { image_url: Some(image.to_base64_url()), detail: Default::default(), }); } } _ => {} } } if !parts.is_empty() { input_items.push(responses::ResponseInputItem::Message { role: "user".into(), content: Some(parts), status: None, }); } } Role::Assistant => { append_reasoning_details_to_response_items( message.reasoning_details.as_deref(), &mut replayed_reasoning_item_indexes, &mut input_items, ); for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { input_items.push(responses::ResponseInputItem::FunctionCall { call_id: tool_use.id.to_string(), name: tool_use.name.to_string(), arguments: tool_use.raw_input.clone(), status: None, thought_signature: tool_use.thought_signature.clone(), }); } } let mut parts: Vec = Vec::new(); for content in &message.content { match content { MessageContent::Text(text) => { parts.push(responses::ResponseInputContent::OutputText { text: text.clone(), }); } MessageContent::Image(_) => { parts.push(responses::ResponseInputContent::OutputText { text: "[image omitted]".to_string(), }); } _ => {} } } if !parts.is_empty() { input_items.push(responses::ResponseInputItem::Message { role: "assistant".into(), content: Some(parts), status: Some("completed".into()), }); } } Role::System => { let mut parts: Vec = Vec::new(); for content in &message.content { if let MessageContent::Text(text) = content { parts.push(responses::ResponseInputContent::InputText { text: text.clone(), }); } } if !parts.is_empty() { input_items.push(responses::ResponseInputItem::Message { role: "system".into(), content: Some(parts), status: None, }); } } } } let converted_tools: Vec = tools .into_iter() .map(|tool| match tool.input { language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { Ok(responses::ToolDefinition::Function { name: tool.name, description: Some(tool.description), parameters: Some(input_schema), strict: None, }) } language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!( "Copilot Chat does not support custom tools" )), }) .collect::>()?; let mapped_tool_choice = tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => responses::ToolChoice::Auto, LanguageModelToolChoice::Any => responses::ToolChoice::Required, LanguageModelToolChoice::None => responses::ToolChoice::None, }); Ok(responses::Request { model: model.id().to_string(), input: input_items, stream: model.uses_streaming(), temperature, tools: converted_tools, tool_choice: mapped_tool_choice, reasoning: if thinking_allowed { let effort = thinking_effort .as_deref() .and_then(|e| e.parse::().ok()) .unwrap_or(copilot_responses::ReasoningEffort::Medium); Some(copilot_responses::ReasoningConfig { effort, summary: Some(copilot_responses::ReasoningSummary::Detailed), }) } else { None }, include: Some(vec![ copilot_responses::ResponseIncludable::ReasoningEncryptedContent, ]), store: false, }) } #[cfg(test)] mod tests { use super::*; use copilot_chat::responses; use futures::StreamExt; use serde_json::json; fn map_events(events: Vec) -> Vec { futures::executor::block_on(async { CopilotResponsesEventMapper::new() .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) .collect::>() .await .into_iter() .map(Result::unwrap) .collect() }) } fn test_responses_model() -> CopilotChatModel { serde_json::from_value(json!({ "billing": { "is_premium": false, "multiplier": 1.0 }, "capabilities": { "family": "test", "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 4096 }, "supports": { "streaming": true, "tool_calls": true, "parallel_tool_calls": false, "vision": false }, "type": "chat" }, "id": "test-model", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Test Model", "vendor": "OpenAI", "supported_endpoints": ["/responses"] })) .expect("valid test model") } #[test] fn responses_stream_maps_text_and_usage() { let events = vec![ responses::StreamEvent::OutputItemAdded { output_index: 0, sequence_number: None, item: responses::ResponseOutputItem::Message { id: "msg_1".into(), role: "assistant".into(), content: Some(Vec::new()), }, }, responses::StreamEvent::OutputTextDelta { item_id: "msg_1".into(), output_index: 0, delta: "Hello".into(), }, responses::StreamEvent::Completed { response: responses::Response { usage: Some(responses::ResponseUsage { input_tokens: Some(5), output_tokens: Some(3), total_tokens: Some(8), }), ..Default::default() }, }, ]; let mapped = map_events(events); assert!(matches!( mapped[0], LanguageModelCompletionEvent::StartMessage { ref message_id } if message_id == "msg_1" )); assert!(matches!( mapped[1], LanguageModelCompletionEvent::Text(ref text) if text == "Hello" )); assert!(matches!( mapped[2], LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: 5, output_tokens: 3, .. }) )); assert!(matches!( mapped[3], LanguageModelCompletionEvent::Stop(StopReason::EndTurn) )); } #[test] fn responses_stream_maps_tool_calls() { let events = vec![responses::StreamEvent::OutputItemDone { output_index: 0, sequence_number: None, item: responses::ResponseOutputItem::FunctionCall { id: Some("fn_1".into()), call_id: "call_1".into(), name: "do_it".into(), arguments: "{\"x\":1}".into(), status: None, thought_signature: None, }, }]; let mapped = map_events(events); assert!(matches!( mapped[0], LanguageModelCompletionEvent::ToolUse(ref use_) if use_.id.to_string() == "call_1" && use_.name.as_ref() == "do_it" )); assert!(matches!( mapped[1], LanguageModelCompletionEvent::Stop(StopReason::ToolUse) )); } #[test] fn responses_stream_handles_json_parse_error() { let events = vec![responses::StreamEvent::OutputItemDone { output_index: 0, sequence_number: None, item: responses::ResponseOutputItem::FunctionCall { id: Some("fn_1".into()), call_id: "call_1".into(), name: "do_it".into(), arguments: "{not json}".into(), status: None, thought_signature: None, }, }]; let mapped = map_events(events); assert!(matches!( mapped[0], LanguageModelCompletionEvent::ToolUseJsonParseError { ref id, ref tool_name, .. } if id.to_string() == "call_1" && tool_name.as_ref() == "do_it" )); assert!(matches!( mapped[1], LanguageModelCompletionEvent::Stop(StopReason::ToolUse) )); } #[test] fn responses_stream_maps_reasoning_summary_and_encrypted_content() { let events = vec![responses::StreamEvent::OutputItemDone { output_index: 0, sequence_number: None, item: responses::ResponseOutputItem::Reasoning { id: "r1".into(), summary: Some(vec![responses::ResponseReasoningItem { kind: "summary_text".into(), text: "Chain".into(), }]), encrypted_content: Some("ENC".into()), }, }]; let mapped = map_events(events); assert!(matches!( mapped[0], LanguageModelCompletionEvent::Thinking { ref text, signature: None } if text == "Chain" )); match &mapped[1] { LanguageModelCompletionEvent::ReasoningDetails(details) => assert_eq!( details, &json!({ "reasoning_items": [ { "id": "r1", "summary": [], "encrypted_content": "ENC" } ] }) ), other => panic!("expected reasoning details, got {other:?}"), } } #[test] fn responses_stream_ignores_reasoning_items_repeated_in_completed_output() { let events = vec![ responses::StreamEvent::OutputItemDone { output_index: 0, sequence_number: None, item: responses::ResponseOutputItem::Reasoning { id: "r1".into(), summary: Some(Vec::new()), encrypted_content: Some("ENC1".into()), }, }, responses::StreamEvent::Completed { response: responses::Response { output: vec![ responses::ResponseOutputItem::Reasoning { id: "r1".into(), summary: Some(Vec::new()), encrypted_content: Some("ENC1".into()), }, responses::ResponseOutputItem::Reasoning { id: "r2".into(), summary: Some(Vec::new()), encrypted_content: Some("ENC2".into()), }, ], ..Default::default() }, }, ]; let mapped = map_events(events); let reasoning_details = mapped .iter() .filter_map(|event| match event { LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details), _ => None, }) .collect::>(); assert_eq!( reasoning_details, vec![&json!({ "reasoning_items": [ { "id": "r1", "summary": [], "encrypted_content": "ENC1" } ] })] ); } #[test] fn into_copilot_responses_replays_reasoning_details() { let model = test_responses_model(); let request = LanguageModelRequest { messages: vec![LanguageModelRequestMessage { role: Role::Assistant, content: vec![ MessageContent::RedactedThinking("legacy-redacted".into()), MessageContent::Text("Done".into()), ], cache: false, reasoning_details: Some(Arc::new(json!({ "reasoning_items": [ { "id": "r1", "summary": [ { "type": "summary_text", "text": "Chain" } ], "encrypted_content": "ENC" } ] }))), }], ..Default::default() }; let serialized = serde_json::to_value(into_copilot_responses(&model, request).unwrap()) .expect("serialized request"); let input = serialized["input"].as_array().expect("input items"); assert_eq!( input.first(), Some(&json!({ "type": "reasoning", "id": "r1", "summary": [], "encrypted_content": "ENC" })) ); assert_eq!( input.get(1), Some(&json!({ "type": "message", "role": "assistant", "content": [ { "type": "output_text", "text": "Done" } ], "status": "completed" })) ); assert!(!serialized.to_string().contains("legacy-redacted")); } #[test] fn responses_stream_handles_incomplete_max_tokens() { let events = vec![responses::StreamEvent::Incomplete { response: responses::Response { usage: Some(responses::ResponseUsage { input_tokens: Some(10), output_tokens: Some(0), total_tokens: Some(10), }), incomplete_details: Some(responses::IncompleteDetails { reason: Some(responses::IncompleteReason::MaxOutputTokens), }), ..Default::default() }, }]; let mapped = map_events(events); assert!(matches!( mapped[0], LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: 10, output_tokens: 0, .. }) )); assert!(matches!( mapped[1], LanguageModelCompletionEvent::Stop(StopReason::MaxTokens) )); } #[test] fn responses_stream_handles_incomplete_content_filter() { let events = vec![responses::StreamEvent::Incomplete { response: responses::Response { usage: None, incomplete_details: Some(responses::IncompleteDetails { reason: Some(responses::IncompleteReason::ContentFilter), }), ..Default::default() }, }]; let mapped = map_events(events); assert!(matches!( mapped.last().unwrap(), LanguageModelCompletionEvent::Stop(StopReason::Refusal) )); } #[test] fn responses_stream_completed_no_duplicate_after_tool_use() { let events = vec![ responses::StreamEvent::OutputItemDone { output_index: 0, sequence_number: None, item: responses::ResponseOutputItem::FunctionCall { id: Some("fn_1".into()), call_id: "call_1".into(), name: "do_it".into(), arguments: "{}".into(), status: None, thought_signature: None, }, }, responses::StreamEvent::Completed { response: responses::Response::default(), }, ]; let mapped = map_events(events); let mut stop_count = 0usize; let mut saw_tool_use_stop = false; for event in mapped { if let LanguageModelCompletionEvent::Stop(reason) = event { stop_count += 1; if matches!(reason, StopReason::ToolUse) { saw_tool_use_stop = true; } } } assert_eq!(stop_count, 1, "should emit exactly one Stop event"); assert!(saw_tool_use_stop, "Stop reason should be ToolUse"); } #[test] fn responses_stream_failed_maps_http_response_error() { let events = vec![responses::StreamEvent::Failed { response: responses::Response { error: Some(responses::ResponseError { code: "429".into(), message: "too many requests".into(), }), ..Default::default() }, }]; let mapped_results = futures::executor::block_on(async { CopilotResponsesEventMapper::new() .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) .collect::>() .await }); assert_eq!(mapped_results.len(), 1); match &mapped_results[0] { Err(LanguageModelCompletionError::HttpResponseError { status_code, message, .. }) => { assert_eq!(*status_code, http_client::StatusCode::TOO_MANY_REQUESTS); assert_eq!(message, "too many requests"); } other => panic!("expected HttpResponseError, got {:?}", other), } } #[test] fn chat_completions_stream_maps_reasoning_data() { use copilot_chat::{ FunctionChunk, ResponseChoice, ResponseDelta, ResponseEvent, Role, ToolCallChunk, }; let events = vec![ ResponseEvent { choices: vec![ResponseChoice { index: Some(0), finish_reason: None, delta: Some(ResponseDelta { content: None, role: Some(Role::Assistant), tool_calls: vec![ToolCallChunk { index: Some(0), id: Some("call_abc123".to_string()), function: Some(FunctionChunk { name: Some("list_directory".to_string()), arguments: Some("{\"path\":\"test\"}".to_string()), thought_signature: None, }), }], reasoning_opaque: Some("encrypted_reasoning_token_xyz".to_string()), reasoning_text: Some("Let me check the directory".to_string()), }), message: None, }], id: "chatcmpl-123".to_string(), usage: None, }, ResponseEvent { choices: vec![ResponseChoice { index: Some(0), finish_reason: Some("tool_calls".to_string()), delta: Some(ResponseDelta { content: None, role: None, tool_calls: vec![], reasoning_opaque: None, reasoning_text: None, }), message: None, }], id: "chatcmpl-123".to_string(), usage: None, }, ]; let mapped = futures::executor::block_on(async { map_to_language_model_completion_events( Box::pin(futures::stream::iter(events.into_iter().map(Ok))), true, ) .collect::>() .await }); let mut has_reasoning_details = false; let mut has_tool_use = false; let mut reasoning_opaque_value: Option = None; let mut reasoning_text_value: Option = None; for event_result in mapped { match event_result { Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => { has_reasoning_details = true; reasoning_opaque_value = details .get("reasoning_opaque") .and_then(|v| v.as_str()) .map(|s| s.to_string()); reasoning_text_value = details .get("reasoning_text") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { has_tool_use = true; assert_eq!(tool_use.id.to_string(), "call_abc123"); assert_eq!(tool_use.name.as_ref(), "list_directory"); } _ => {} } } assert!( has_reasoning_details, "Should emit ReasoningDetails event for Gemini 3 reasoning" ); assert!(has_tool_use, "Should emit ToolUse event"); assert_eq!( reasoning_opaque_value, Some("encrypted_reasoning_token_xyz".to_string()), "Should capture reasoning_opaque" ); assert_eq!( reasoning_text_value, Some("Let me check the directory".to_string()), "Should capture reasoning_text" ); } }