diff --git a/Cargo.lock b/Cargo.lock index 1c50600e16..a216a5fa48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3887,16 +3887,17 @@ version = "0.1.0" dependencies = [ "anthropic", "anyhow", + "collections", "credentials_provider", - "dirs", "futures 0.3.32", "gpui", "http_client", + "language_model", "log", - "paths", "serde", "serde_json", "settings", + "util", "zed_credentials_provider", ] diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 6e9bc4ad2e..fed2f4bc9a 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -833,10 +833,7 @@ impl Copilot { anyhow::Ok(()) }) } - CopilotServer::Disabled => cx.background_spawn(async { - clear_copilot_config_dir().await; - anyhow::Ok(()) - }), + CopilotServer::Disabled => cx.background_spawn(async { anyhow::Ok(()) }), _ => Task::ready(Err(anyhow!("copilot hasn't started yet"))), } } @@ -1357,10 +1354,6 @@ async fn clear_copilot_dir() { remove_matching(paths::copilot_dir(), |_| true).await } -async fn clear_copilot_config_dir() { - remove_matching(copilot_chat::copilot_chat_config_dir(), |_| true).await -} - async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow::Result { const PACKAGE_NAME: &str = "@github/copilot-language-server"; const SERVER_PATH: &str = diff --git a/crates/copilot_chat/Cargo.toml b/crates/copilot_chat/Cargo.toml index 652c736654..99a20fa5b5 100644 --- a/crates/copilot_chat/Cargo.toml +++ b/crates/copilot_chat/Cargo.toml @@ -22,16 +22,17 @@ test-support = [ [dependencies] anthropic.workspace = true anyhow.workspace = true +collections.workspace = true credentials_provider.workspace = true -dirs.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true +language_model.workspace = true log.workspace = true -paths.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +util.workspace = true zed_credentials_provider.workspace = true [dev-dependencies] diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 5d03a40b47..3c1a16cdda 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -1,19 +1,19 @@ pub mod copilot_oauth; +mod model; pub mod responses; -use std::path::PathBuf; +pub use model::{PROVIDER_ID, PROVIDER_NAME, create_language_model}; + use std::sync::Arc; -use std::sync::OnceLock; use anyhow::Context as _; use anyhow::{Result, anyhow}; use credentials_provider::CredentialsProvider; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use gpui::WeakEntity; -use gpui::{App, AsyncApp, Global, Task, prelude::*}; +use gpui::{App, AsyncApp, Entity, Global, Task, prelude::*}; use http_client::HttpRequestExt; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; -use paths::home_dir; use serde::{Deserialize, Serialize}; pub use copilot_oauth::DeviceFlow; @@ -541,22 +541,6 @@ pub fn init(client: Arc, configuration: CopilotChatConfiguration cx.set_global(GlobalCopilotChat(copilot_chat)); } -pub fn copilot_chat_config_dir() -> &'static PathBuf { - static COPILOT_CHAT_CONFIG_DIR: OnceLock = OnceLock::new(); - - COPILOT_CHAT_CONFIG_DIR.get_or_init(|| { - let config_dir = if cfg!(target_os = "windows") { - dirs::data_local_dir().expect("failed to determine LocalAppData directory") - } else { - std::env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".config")) - }; - - config_dir.join("github-copilot") - }) -} - fn oauth_token_from_env() -> Option { std::env::var(COPILOT_OAUTH_ENV_VAR) .ok() @@ -581,7 +565,7 @@ impl CopilotChat { .map(|model| model.0.clone()) } - fn new( + pub fn new( client: Arc, credentials_provider: Arc, configuration: CopilotChatConfiguration, @@ -756,13 +740,14 @@ impl CopilotChat { } pub async fn stream_completion( + copilot_chat: Entity, request: Request, location: ChatLocation, is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { let (client, oauth_token, api_endpoint, configuration) = - Self::get_auth_details(&mut cx).await?; + Self::get_auth_details(&copilot_chat, &mut cx).await?; let api_url = configuration.chat_completions_url(&api_endpoint); stream_completion( @@ -777,13 +762,14 @@ impl CopilotChat { } pub async fn stream_response( + copilot_chat: Entity, request: responses::Request, location: ChatLocation, is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { let (client, oauth_token, api_endpoint, configuration) = - Self::get_auth_details(&mut cx).await?; + Self::get_auth_details(&copilot_chat, &mut cx).await?; let api_url = configuration.responses_url(&api_endpoint); responses::stream_response( @@ -798,6 +784,7 @@ impl CopilotChat { } pub async fn stream_messages( + copilot_chat: Entity, body: String, location: ChatLocation, is_user_initiated: bool, @@ -805,7 +792,7 @@ impl CopilotChat { mut cx: AsyncApp, ) -> Result>> { let (client, oauth_token, api_endpoint, configuration) = - Self::get_auth_details(&mut cx).await?; + Self::get_auth_details(&copilot_chat, &mut cx).await?; let api_url = configuration.messages_url(&api_endpoint); stream_messages( @@ -821,6 +808,7 @@ impl CopilotChat { } async fn get_auth_details( + copilot_chat: &Entity, cx: &mut AsyncApp, ) -> Result<( Arc, @@ -828,25 +816,22 @@ impl CopilotChat { String, CopilotChatConfiguration, )> { - let this = cx - .update(|cx| Self::global(cx)) - .context("Copilot chat is not enabled")?; - - let (oauth_token, api_endpoint, client, configuration) = this.read_with(cx, |this, _| { - ( - this.oauth_token.clone(), - this.api_endpoint.clone(), - this.client.clone(), - this.configuration.clone(), - ) - }); + let (oauth_token, api_endpoint, client, configuration) = + copilot_chat.read_with(cx, |copilot_chat, _| { + ( + copilot_chat.oauth_token.clone(), + copilot_chat.api_endpoint.clone(), + copilot_chat.client.clone(), + copilot_chat.configuration.clone(), + ) + }); let oauth_token = oauth_token.context("No OAuth token available")?; let api_endpoint = match api_endpoint { Some(endpoint) => endpoint, None => { - let weak = this.downgrade(); + let weak = copilot_chat.downgrade(); Self::resolve_api_endpoint(&weak, &oauth_token, &configuration, &client, cx).await? } }; diff --git a/crates/copilot_chat/src/copilot_oauth.rs b/crates/copilot_chat/src/copilot_oauth.rs index c65e5ca243..5f85a9be12 100644 --- a/crates/copilot_chat/src/copilot_oauth.rs +++ b/crates/copilot_chat/src/copilot_oauth.rs @@ -17,9 +17,7 @@ use serde::Deserialize; use crate::CopilotChatConfiguration; -/// The public OAuth application client ID used by GitHub Copilot editor -/// integrations (the same one the Copilot language server uses). Reusing it -/// lets Zed request the same Copilot scopes without registering its own app. +/// The public OAuth application client ID of Zed pub const GITHUB_COPILOT_CLIENT_ID: &str = "6e3a0413e62d19d75ff1"; const DEVICE_CODE_SCOPE: &str = "read:user"; diff --git a/crates/copilot_chat/src/model.rs b/crates/copilot_chat/src/model.rs new file mode 100644 index 0000000000..eb0f9d2eef --- /dev/null +++ b/crates/copilot_chat/src/model.rs @@ -0,0 +1,1785 @@ +use std::{pin::Pin, str::FromStr as _, sync::Arc}; + +use crate::responses as copilot_responses; +use crate::{ + ChatLocation, ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, Function, + FunctionContent, ImageUrl, Model as CopilotChatModel, ModelVendor, + Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent, ToolChoice, +}; +use anthropic::{ + AnthropicModelMode, + completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}, +}; +use anyhow::{Result, anyhow}; +use collections::HashMap; +use futures::future::BoxFuture; +use futures::stream::BoxStream; +use futures::{FutureExt, Stream, StreamExt}; +use gpui::{AsyncApp, Entity}; +use http_client::StatusCode; +use language_model::{ + CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelCostInfo, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role, + StopReason, TokenUsage, +}; +use util::debug_panic; + +use language_model::util::{fix_streamed_json, parse_tool_arguments}; + +pub const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); +pub const PROVIDER_NAME: LanguageModelProviderName = + LanguageModelProviderName::new("GitHub Copilot Chat"); + +pub fn create_language_model( + model: CopilotChatModel, + copilot_chat: Entity, +) -> Arc { + Arc::new(CopilotChatLanguageModel { + model, + copilot_chat, + request_limiter: RateLimiter::new(4), + }) +} + +struct CopilotChatLanguageModel { + model: CopilotChatModel, + copilot_chat: Entity, + 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 copilot_chat = self.copilot_chat.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( + copilot_chat, + 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 copilot_chat = self.copilot_chat.clone(); + let request_limiter = self.request_limiter.clone(); + let future = cx.spawn(async move |cx| { + let request = CopilotChat::stream_response( + copilot_chat, + 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 copilot_chat = self.copilot_chat.clone(); + let request_limiter = self.request_limiter.clone(); + let future = cx.spawn(async move |cx| { + let request = CopilotChat::stream_completion( + copilot_chat, + 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 crate::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 crate::{ + 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" + ); + } +} diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 8331eaac08..db39f17c68 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -1,42 +1,18 @@ -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_chat::responses as copilot_responses; +use anyhow::Result; use copilot_chat::{ - ChatLocation, ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, - CopilotChatConfiguration, Function, FunctionContent, ImageUrl, Model as CopilotChatModel, - ModelVendor, Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent, - ToolChoice, + CopilotChat, CopilotChatConfiguration, Model as CopilotChatModel, PROVIDER_ID, PROVIDER_NAME, + create_language_model, }; -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 gpui::{App, Entity, Subscription, Task}; 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, + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, ProviderSettingsView, }; 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, @@ -50,7 +26,7 @@ pub struct State { impl State { fn is_authenticated(&self, cx: &App) -> bool { CopilotChat::global(cx) - .map(|m| m.read(cx).is_authenticated()) + .map(|model| model.read(cx).is_authenticated()) .unwrap_or(false) } } @@ -84,11 +60,12 @@ impl CopilotChatLanguageModelProvider { Self { state } } - fn create_language_model(&self, model: CopilotChatModel) -> Arc { - Arc::new(CopilotChatLanguageModel { - model, - request_limiter: RateLimiter::new(4), - }) + fn create_language_model( + &self, + model: CopilotChatModel, + copilot_chat: Entity, + ) -> Arc { + create_language_model(model, copilot_chat) } } @@ -114,10 +91,9 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { } 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())) + let copilot_chat = CopilotChat::global(cx)?; + let model = copilot_chat.read(cx).models()?.first()?.clone(); + Some(self.create_language_model(model, copilot_chat)) } fn default_fast_model(&self, cx: &App) -> Option> { @@ -127,12 +103,16 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { } fn provided_models(&self, cx: &App) -> Vec> { - let Some(models) = CopilotChat::global(cx).and_then(|m| m.read(cx).models()) else { + let Some(copilot_chat) = CopilotChat::global(cx) else { + return Vec::new(); + }; + let Some(models) = copilot_chat.read(cx).models() else { return Vec::new(); }; models .iter() - .map(|model| self.create_language_model(model.clone())) + .cloned() + .map(|model| self.create_language_model(model, copilot_chat.clone())) .collect() } @@ -172,7 +152,7 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { copilot_ui::ConfigurationView::new( |cx| { CopilotChat::global(cx) - .map(|m| m.read(cx).is_authenticated()) + .map(|model| model.read(cx).is_authenticated()) .unwrap_or(false) }, copilot_ui::ConfigurationMode::Chat, @@ -199,1736 +179,3 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { copilot_chat.update(cx, |chat, cx| chat.sign_out(cx)) } } - -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" - ); - } -}