pub mod responses; use std::path::PathBuf; use std::sync::Arc; use std::sync::OnceLock; use anyhow::Context as _; use anyhow::{Result, anyhow}; use collections::HashSet; use fs::Fs; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use gpui::TaskExt; use gpui::WeakEntity; use gpui::{App, AsyncApp, Global, prelude::*}; use http_client::HttpRequestExt; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use paths::home_dir; use serde::{Deserialize, Serialize}; use settings::watch_config_dir; pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN"; const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com"; #[derive(Default, Clone, Debug, PartialEq)] pub struct CopilotChatConfiguration { pub enterprise_uri: Option, } impl CopilotChatConfiguration { pub fn oauth_domain(&self) -> String { if let Some(enterprise_uri) = &self.enterprise_uri { Self::parse_domain(enterprise_uri) } else { "github.com".to_string() } } pub fn graphql_url(&self) -> String { if let Some(enterprise_uri) = &self.enterprise_uri { let domain = Self::parse_domain(enterprise_uri); format!("https://{}/api/graphql", domain) } else { "https://api.github.com/graphql".to_string() } } pub fn chat_completions_url(&self, api_endpoint: &str) -> String { format!("{}/chat/completions", api_endpoint) } pub fn responses_url(&self, api_endpoint: &str) -> String { format!("{}/responses", api_endpoint) } pub fn messages_url(&self, api_endpoint: &str) -> String { format!("{}/v1/messages", api_endpoint) } pub fn models_url(&self, api_endpoint: &str) -> String { format!("{}/models", api_endpoint) } fn parse_domain(enterprise_uri: &str) -> String { let uri = enterprise_uri.trim_end_matches('/'); if let Some(domain) = uri.strip_prefix("https://") { domain.split('/').next().unwrap_or(domain).to_string() } else if let Some(domain) = uri.strip_prefix("http://") { domain.split('/').next().unwrap_or(domain).to_string() } else { uri.split('/').next().unwrap_or(uri).to_string() } } } #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Role { User, Assistant, System, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum ChatLocation { #[default] Panel, Editor, EditingSession, Terminal, Agent, Other, } impl ChatLocation { pub fn to_intent_string(self) -> &'static str { match self { ChatLocation::Panel => "conversation-panel", ChatLocation::Editor => "conversation-inline", ChatLocation::EditingSession => "conversation-edits", ChatLocation::Terminal => "conversation-terminal", ChatLocation::Agent => "conversation-agent", ChatLocation::Other => "conversation-other", } } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum ModelSupportedEndpoint { #[serde(rename = "/chat/completions")] ChatCompletions, #[serde(rename = "/responses")] Responses, #[serde(rename = "/v1/messages")] Messages, /// Unknown endpoint that we don't explicitly support yet #[serde(other)] Unknown, } #[derive(Deserialize)] struct ModelSchema { #[serde(deserialize_with = "deserialize_models_skip_errors")] data: Vec, } fn deserialize_models_skip_errors<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { let raw_values = Vec::::deserialize(deserializer)?; let models = raw_values .into_iter() .filter_map(|value| match serde_json::from_value::(value) { Ok(model) => Some(model), Err(err) => { log::warn!("GitHub Copilot Chat model failed to deserialize: {:?}", err); None } }) .collect(); Ok(models) } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct Model { billing: ModelBilling, capabilities: ModelCapabilities, id: String, name: String, policy: Option, vendor: ModelVendor, is_chat_default: bool, // The model with this value true is selected by VSCode copilot if a premium request limit is // reached. Zed does not currently implement this behaviour is_chat_fallback: bool, model_picker_enabled: bool, #[serde(default)] supported_endpoints: Vec, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] struct ModelBilling { is_premium: bool, multiplier: f64, // List of plans a model is restricted to // Field is not present if a model is available for all plans #[serde(default)] restricted_to: Option>, } #[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] struct ModelCapabilities { family: String, #[serde(default)] limits: ModelLimits, supports: ModelSupportedFeatures, #[serde(rename = "type")] model_type: String, #[serde(default)] tokenizer: Option, } #[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] struct ModelLimits { #[serde(default)] max_context_window_tokens: usize, #[serde(default)] max_output_tokens: usize, #[serde(default)] max_prompt_tokens: u64, } #[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] struct ModelPolicy { state: String, } #[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] struct ModelSupportedFeatures { #[serde(default)] streaming: bool, #[serde(default)] tool_calls: bool, #[serde(default)] parallel_tool_calls: bool, #[serde(default)] vision: bool, #[serde(default)] thinking: bool, #[serde(default)] adaptive_thinking: bool, #[serde(default)] max_thinking_budget: Option, #[serde(default)] min_thinking_budget: Option, #[serde(default)] reasoning_effort: Vec, } #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] pub enum ModelVendor { // Azure OpenAI should have no functional difference from OpenAI in Copilot Chat #[serde(alias = "Azure OpenAI")] OpenAI, Google, Anthropic, #[serde(rename = "xAI")] XAI, /// Unknown vendor that we don't explicitly support yet #[serde(other)] Unknown, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] #[serde(tag = "type")] pub enum ChatMessagePart { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "image_url")] Image { image_url: ImageUrl }, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct ImageUrl { pub url: String, } impl Model { pub fn uses_streaming(&self) -> bool { self.capabilities.supports.streaming } pub fn id(&self) -> &str { self.id.as_str() } pub fn display_name(&self) -> &str { self.name.as_str() } pub fn max_token_count(&self) -> u64 { self.capabilities.limits.max_context_window_tokens as u64 } pub fn max_output_tokens(&self) -> usize { self.capabilities.limits.max_output_tokens } pub fn supports_tools(&self) -> bool { self.capabilities.supports.tool_calls } pub fn vendor(&self) -> ModelVendor { self.vendor } pub fn supports_vision(&self) -> bool { self.capabilities.supports.vision } pub fn supports_parallel_tool_calls(&self) -> bool { self.capabilities.supports.parallel_tool_calls } pub fn tokenizer(&self) -> Option<&str> { self.capabilities.tokenizer.as_deref() } pub fn supports_response(&self) -> bool { self.supported_endpoints .contains(&ModelSupportedEndpoint::Responses) } pub fn supports_messages(&self) -> bool { self.supported_endpoints .contains(&ModelSupportedEndpoint::Messages) } pub fn supports_thinking(&self) -> bool { self.capabilities.supports.thinking } pub fn supports_adaptive_thinking(&self) -> bool { self.capabilities.supports.adaptive_thinking } pub fn can_think(&self) -> bool { self.supports_thinking() || self.supports_adaptive_thinking() || self.max_thinking_budget().is_some() || !self.reasoning_effort_levels().is_empty() } pub fn max_thinking_budget(&self) -> Option { self.capabilities.supports.max_thinking_budget } pub fn min_thinking_budget(&self) -> Option { self.capabilities.supports.min_thinking_budget } pub fn reasoning_effort_levels(&self) -> &[String] { &self.capabilities.supports.reasoning_effort } pub fn family(&self) -> &str { &self.capabilities.family } pub fn multiplier(&self) -> f64 { self.billing.multiplier } } #[derive(Serialize, Deserialize)] pub struct Request { pub n: usize, pub stream: bool, pub temperature: f32, pub model: String, pub messages: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_choice: Option, #[serde(skip_serializing_if = "Option::is_none")] pub thinking_budget: Option, } #[derive(Serialize, Deserialize)] pub struct Function { pub name: String, pub description: String, pub parameters: serde_json::Value, } #[derive(Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Tool { Function { function: Function }, } #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, Required, None, } #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "role", rename_all = "lowercase")] pub enum ChatMessage { Assistant { content: ChatMessageContent, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] reasoning_opaque: Option, #[serde(default, skip_serializing_if = "Option::is_none")] reasoning_text: Option, }, User { content: ChatMessageContent, }, System { content: String, }, Tool { content: ChatMessageContent, tool_call_id: String, }, } #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum ChatMessageContent { Plain(String), Multipart(Vec), } impl ChatMessageContent { pub fn empty() -> Self { ChatMessageContent::Multipart(vec![]) } } impl From> for ChatMessageContent { fn from(mut parts: Vec) -> Self { if let [ChatMessagePart::Text { text }] = parts.as_mut_slice() { ChatMessageContent::Plain(std::mem::take(text)) } else { ChatMessageContent::Multipart(parts) } } } impl From for ChatMessageContent { fn from(text: String) -> Self { ChatMessageContent::Plain(text) } } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct ToolCall { pub id: String, #[serde(flatten)] pub content: ToolCallContent, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ToolCallContent { Function { function: FunctionContent }, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct FunctionContent { pub name: String, pub arguments: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub thought_signature: Option, } #[derive(Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub struct ResponseEvent { pub choices: Vec, pub id: String, pub usage: Option, } #[derive(Deserialize, Debug)] pub struct Usage { pub completion_tokens: u64, pub prompt_tokens: u64, pub total_tokens: u64, } #[derive(Debug, Deserialize)] pub struct ResponseChoice { pub index: Option, pub finish_reason: Option, pub delta: Option, pub message: Option, } #[derive(Debug, Deserialize)] pub struct ResponseDelta { pub content: Option, pub role: Option, #[serde(default)] pub tool_calls: Vec, pub reasoning_opaque: Option, pub reasoning_text: Option, } #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct ToolCallChunk { pub index: Option, pub id: Option, pub function: Option, } #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct FunctionChunk { pub name: Option, pub arguments: Option, pub thought_signature: Option, } struct GlobalCopilotChat(gpui::Entity); impl Global for GlobalCopilotChat {} pub struct CopilotChat { oauth_token: Option, api_endpoint: Option, configuration: CopilotChatConfiguration, models: Option>, client: Arc, } pub fn init( fs: Arc, client: Arc, configuration: CopilotChatConfiguration, cx: &mut App, ) { let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, configuration, cx)); 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 copilot_chat_config_paths() -> [PathBuf; 2] { let base_dir = copilot_chat_config_dir(); [base_dir.join("hosts.json"), base_dir.join("apps.json")] } impl CopilotChat { pub fn global(cx: &App) -> Option> { cx.try_global::() .map(|model| model.0.clone()) } fn new( fs: Arc, client: Arc, configuration: CopilotChatConfiguration, cx: &mut Context, ) -> Self { let config_paths: HashSet = copilot_chat_config_paths().into_iter().collect(); let dir_path = copilot_chat_config_dir(); cx.spawn(async move |this, cx| { let mut parent_watch_rx = watch_config_dir( cx.background_executor(), fs.clone(), dir_path.clone(), config_paths, ); while let Some(contents) = parent_watch_rx.next().await { let oauth_domain = this.read_with(cx, |this, _| this.configuration.oauth_domain())?; let oauth_token = extract_oauth_token(contents, &oauth_domain); this.update(cx, |this, cx| { this.oauth_token = oauth_token.clone(); cx.notify(); })?; if oauth_token.is_some() { Self::update_models(&this, cx).await?; } } anyhow::Ok(()) }) .detach_and_log_err(cx); let this = Self { oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(), api_endpoint: None, models: None, configuration, client, }; if this.oauth_token.is_some() { cx.spawn(async move |this, cx| Self::update_models(&this, cx).await) .detach_and_log_err(cx); } this } async fn update_models(this: &WeakEntity, cx: &mut AsyncApp) -> Result<()> { let (oauth_token, client, configuration) = this.read_with(cx, |this, _| { ( this.oauth_token.clone(), this.client.clone(), this.configuration.clone(), ) })?; let oauth_token = oauth_token .ok_or_else(|| anyhow!("OAuth token is missing while updating Copilot Chat models"))?; let api_endpoint = Self::resolve_api_endpoint(&this, &oauth_token, &configuration, &client, cx).await?; let models_url = configuration.models_url(&api_endpoint); let models = get_models(models_url.into(), oauth_token, client.clone()).await?; this.update(cx, |this, cx| { this.models = Some(models); cx.notify(); })?; anyhow::Ok(()) } pub fn is_authenticated(&self) -> bool { self.oauth_token.is_some() } pub fn models(&self) -> Option<&[Model]> { self.models.as_deref() } pub async fn stream_completion( 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?; let api_url = configuration.chat_completions_url(&api_endpoint); stream_completion( client.clone(), oauth_token, api_url.into(), request, is_user_initiated, location, ) .await } pub async fn stream_response( 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?; let api_url = configuration.responses_url(&api_endpoint); responses::stream_response( client.clone(), oauth_token, api_url, request, is_user_initiated, location, ) .await } pub async fn stream_messages( body: String, location: ChatLocation, is_user_initiated: bool, anthropic_beta: Option, mut cx: AsyncApp, ) -> Result>> { let (client, oauth_token, api_endpoint, configuration) = Self::get_auth_details(&mut cx).await?; let api_url = configuration.messages_url(&api_endpoint); stream_messages( client.clone(), oauth_token, api_url, body, is_user_initiated, location, anthropic_beta, ) .await } async fn get_auth_details( cx: &mut AsyncApp, ) -> Result<( Arc, String, 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 = oauth_token.context("No OAuth token available")?; let api_endpoint = match api_endpoint { Some(endpoint) => endpoint, None => { let weak = this.downgrade(); Self::resolve_api_endpoint(&weak, &oauth_token, &configuration, &client, cx).await? } }; Ok((client, oauth_token, api_endpoint, configuration)) } async fn resolve_api_endpoint( this: &WeakEntity, oauth_token: &str, configuration: &CopilotChatConfiguration, client: &Arc, cx: &mut AsyncApp, ) -> Result { let api_endpoint = match discover_api_endpoint(oauth_token, configuration, client).await { Ok(endpoint) => endpoint, Err(error) => { log::warn!( "Failed to discover Copilot API endpoint via GraphQL, \ falling back to {DEFAULT_COPILOT_API_ENDPOINT}: {error:#}" ); DEFAULT_COPILOT_API_ENDPOINT.to_string() } }; this.update(cx, |this, cx| { this.api_endpoint = Some(api_endpoint.clone()); cx.notify(); })?; Ok(api_endpoint) } pub fn set_configuration( &mut self, configuration: CopilotChatConfiguration, cx: &mut Context, ) { let same_configuration = self.configuration == configuration; self.configuration = configuration; if !same_configuration { self.api_endpoint = None; cx.spawn(async move |this, cx| { Self::update_models(&this, cx).await?; Ok::<_, anyhow::Error>(()) }) .detach(); } } } async fn get_models( models_url: Arc, oauth_token: String, client: Arc, ) -> Result> { let all_models = request_models(models_url, oauth_token, client).await?; let mut models: Vec = all_models .into_iter() .filter(|model| { model.model_picker_enabled && model.capabilities.model_type.as_str() == "chat" && model .policy .as_ref() .is_none_or(|policy| policy.state == "enabled") }) .collect(); if let Some(default_model_position) = models.iter().position(|model| model.is_chat_default) { let default_model = models.remove(default_model_position); models.insert(0, default_model); } Ok(models) } #[derive(Deserialize)] struct GraphQLResponse { data: Option, } #[derive(Deserialize)] struct GraphQLData { viewer: GraphQLViewer, } #[derive(Deserialize)] struct GraphQLViewer { #[serde(rename = "copilotEndpoints")] copilot_endpoints: GraphQLCopilotEndpoints, } #[derive(Deserialize)] struct GraphQLCopilotEndpoints { api: String, } pub(crate) async fn discover_api_endpoint( oauth_token: &str, configuration: &CopilotChatConfiguration, client: &Arc, ) -> Result { let graphql_url = configuration.graphql_url(); let query = serde_json::json!({ "query": "query { viewer { copilotEndpoints { api } } }" }); let request = HttpRequest::builder() .method(Method::POST) .uri(graphql_url.as_str()) .header("Authorization", format!("Bearer {}", oauth_token)) .header("Content-Type", "application/json") .body(AsyncBody::from(serde_json::to_string(&query)?))?; let mut response = client.send(request).await?; anyhow::ensure!( response.status().is_success(), "GraphQL endpoint discovery failed: {}", response.status() ); let mut body = Vec::new(); response.body_mut().read_to_end(&mut body).await?; let body_str = std::str::from_utf8(&body)?; let parsed: GraphQLResponse = serde_json::from_str(body_str) .context("Failed to parse GraphQL response for Copilot endpoint discovery")?; let data = parsed .data .context("GraphQL response contained no data field")?; Ok(data.viewer.copilot_endpoints.api) } pub(crate) fn copilot_request_headers( builder: http_client::Builder, oauth_token: &str, is_user_initiated: Option, location: Option, ) -> http_client::Builder { builder .header("Authorization", format!("Bearer {}", oauth_token)) .header("Content-Type", "application/json") .header( "Editor-Version", format!( "Zed/{}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown") ), ) .header("X-GitHub-Api-Version", "2025-10-01") .when_some(is_user_initiated, |builder, is_user_initiated| { builder.header( "X-Initiator", if is_user_initiated { "user" } else { "agent" }, ) }) .when_some(location, |builder, loc| { let interaction_type = loc.to_intent_string(); builder .header("X-Interaction-Type", interaction_type) .header("OpenAI-Intent", interaction_type) }) } async fn request_models( models_url: Arc, oauth_token: String, client: Arc, ) -> Result> { let request_builder = copilot_request_headers( HttpRequest::builder() .method(Method::GET) .uri(models_url.as_ref()), &oauth_token, None, None, ); let request = request_builder.body(AsyncBody::empty())?; let mut response = client.send(request).await?; anyhow::ensure!( response.status().is_success(), "Failed to request models: {}", response.status() ); let mut body = Vec::new(); response.body_mut().read_to_end(&mut body).await?; let body_str = std::str::from_utf8(&body)?; let models = serde_json::from_str::(body_str)?.data; Ok(models) } fn extract_oauth_token(contents: String, domain: &str) -> Option { serde_json::from_str::(&contents) .map(|v| { v.as_object().and_then(|obj| { obj.iter().find_map(|(key, value)| { if key.starts_with(domain) { value["oauth_token"].as_str().map(|v| v.to_string()) } else { None } }) }) }) .ok() .flatten() } async fn stream_completion( client: Arc, oauth_token: String, completion_url: Arc, request: Request, is_user_initiated: bool, location: ChatLocation, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { ChatMessage::User { content } | ChatMessage::Assistant { content, .. } | ChatMessage::Tool { content, .. } => { matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. }))) } _ => false, }); let request_builder = copilot_request_headers( HttpRequest::builder() .method(Method::POST) .uri(completion_url.as_ref()), &oauth_token, Some(is_user_initiated), Some(location), ) .when(is_vision_request, |builder| { builder.header("Copilot-Vision-Request", is_vision_request.to_string()) }); let is_streaming = request.stream; let json = serde_json::to_string(&request)?; let request = request_builder.body(AsyncBody::from(json))?; let mut response = client.send(request).await?; if !response.status().is_success() { let mut body = Vec::new(); response.body_mut().read_to_end(&mut body).await?; let body_str = std::str::from_utf8(&body)?; anyhow::bail!( "Failed to connect to API: {} {}", response.status(), body_str ); } if is_streaming { let reader = BufReader::new(response.into_body()); Ok(reader .lines() .filter_map(|line| async move { match line { Ok(line) => { let line = line.strip_prefix("data: ")?; if line.starts_with("[DONE]") { return None; } match serde_json::from_str::(line) { Ok(response) => { if response.choices.is_empty() { None } else { Some(Ok(response)) } } Err(error) => Some(Err(anyhow!(error))), } } Err(error) => Some(Err(anyhow!(error))), } }) .boxed()) } else { let mut body = Vec::new(); response.body_mut().read_to_end(&mut body).await?; let body_str = std::str::from_utf8(&body)?; let response: ResponseEvent = serde_json::from_str(body_str)?; Ok(futures::stream::once(async move { Ok(response) }).boxed()) } } async fn stream_messages( client: Arc, oauth_token: String, api_url: String, body: String, is_user_initiated: bool, location: ChatLocation, anthropic_beta: Option, ) -> Result>> { let mut request_builder = copilot_request_headers( HttpRequest::builder().method(Method::POST).uri(&api_url), &oauth_token, Some(is_user_initiated), Some(location), ); if let Some(beta) = &anthropic_beta { request_builder = request_builder.header("anthropic-beta", beta.as_str()); } let request = request_builder.body(AsyncBody::from(body))?; let mut response = client.send(request).await?; if !response.status().is_success() { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; anyhow::bail!("Failed to connect to API: {} {}", response.status(), body); } let reader = BufReader::new(response.into_body()); Ok(reader .lines() .filter_map(|line| async move { match line { Ok(line) => { let line = line .strip_prefix("data: ") .or_else(|| line.strip_prefix("data:"))?; if line.starts_with("[DONE]") || line.is_empty() { return None; } match serde_json::from_str(line) { Ok(event) => Some(Ok(event)), Err(error) => { log::error!( "Failed to parse Copilot messages stream event: `{}`\nResponse: `{}`", error, line, ); Some(Err(anthropic::AnthropicError::DeserializeResponse(error))) } } } Err(error) => Some(Err(anthropic::AnthropicError::ReadResponse(error))), } }) .boxed()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_resilient_model_schema_deserialize() { let json = r#"{ "data": [ { "billing": { "is_premium": false, "multiplier": 0 }, "capabilities": { "family": "gpt-4", "limits": { "max_context_window_tokens": 32768, "max_output_tokens": 4096, "max_prompt_tokens": 32768 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "tokenizer": "cl100k_base", "type": "chat" }, "id": "gpt-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": false, "name": "GPT 4", "object": "model", "preview": false, "vendor": "Azure OpenAI", "version": "gpt-4-0613" }, { "some-unknown-field": 123 }, { "billing": { "is_premium": true, "multiplier": 1, "restricted_to": [ "pro", "pro_plus", "business", "enterprise" ] }, "capabilities": { "family": "claude-3.7-sonnet", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000, "vision": { "max_prompt_image_size": 3145728, "max_prompt_images": 1, "supported_media_types": ["image/jpeg", "image/png", "image/webp"] } }, "object": "model_capabilities", "supports": { "parallel_tool_calls": true, "streaming": true, "tool_calls": true, "vision": true }, "tokenizer": "o200k_base", "type": "chat" }, "id": "claude-3.7-sonnet", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude 3.7 Sonnet", "object": "model", "policy": { "state": "enabled", "terms": "Enable access to the latest Claude 3.7 Sonnet model from Anthropic. [Learn more about how GitHub Copilot serves Claude 3.7 Sonnet](https://docs.github.com/copilot/using-github-copilot/using-claude-sonnet-in-github-copilot)." }, "preview": false, "vendor": "Anthropic", "version": "claude-3.7-sonnet" } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); assert_eq!(schema.data.len(), 2); assert_eq!(schema.data[0].id, "gpt-4"); assert_eq!(schema.data[1].id, "claude-3.7-sonnet"); } #[test] fn test_unknown_vendor_resilience() { let json = r#"{ "data": [ { "billing": { "is_premium": false, "multiplier": 1 }, "capabilities": { "family": "future-model", "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 8192, "max_prompt_tokens": 120000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "future-model-v1", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Future Model v1", "object": "model", "preview": false, "vendor": "SomeNewVendor", "version": "v1.0" } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); assert_eq!(schema.data.len(), 1); assert_eq!(schema.data[0].id, "future-model-v1"); assert_eq!(schema.data[0].vendor, ModelVendor::Unknown); } #[test] fn test_max_token_count_returns_context_window_not_prompt_tokens() { let json = r#"{ "data": [ { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4" }, { "billing": { "is_premium": false, "multiplier": 1 }, "capabilities": { "family": "gpt-4o", "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "gpt-4o", "is_chat_default": true, "is_chat_fallback": false, "model_picker_enabled": true, "name": "GPT-4o", "object": "model", "preview": false, "vendor": "Azure OpenAI", "version": "gpt-4o" } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); // max_token_count() should return context window (200000), not prompt tokens (90000) assert_eq!(schema.data[0].max_token_count(), 200000); // GPT-4o should return 128000 (context window), not 110000 (prompt tokens) assert_eq!(schema.data[1].max_token_count(), 128000); } #[test] fn test_models_with_pending_policy_deserialize() { // This test verifies that models with policy states other than "enabled" // (such as "pending" or "requires_consent") are properly deserialized. // Note: These models will be filtered out by get_models() and won't appear // in the model picker until the user enables them on GitHub. let json = r#"{ "data": [ { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4", "object": "model", "policy": { "state": "pending", "terms": "Enable access to Claude models from Anthropic." }, "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4" }, { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-opus-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-opus-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Opus 4", "object": "model", "policy": { "state": "requires_consent", "terms": "Enable access to Claude models from Anthropic." }, "preview": false, "vendor": "Anthropic", "version": "claude-opus-4" } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); // Both models should deserialize successfully (filtering happens in get_models) assert_eq!(schema.data.len(), 2); assert_eq!(schema.data[0].id, "claude-sonnet-4"); assert_eq!(schema.data[1].id, "claude-opus-4"); } #[test] fn test_multiple_anthropic_models_preserved() { // This test verifies that multiple Claude models from Anthropic // are all preserved and not incorrectly deduplicated. // This was the root cause of issue #47540. let json = r#"{ "data": [ { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4" }, { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-opus-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-opus-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Opus 4", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-opus-4" }, { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4.5", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4.5", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4.5", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4.5" } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); // All three Anthropic models should be preserved assert_eq!(schema.data.len(), 3); assert_eq!(schema.data[0].id, "claude-sonnet-4"); assert_eq!(schema.data[1].id, "claude-opus-4"); assert_eq!(schema.data[2].id, "claude-sonnet-4.5"); } #[test] fn test_models_with_same_family_both_preserved() { // Test that models sharing the same family (e.g., thinking variants) // are both preserved in the model list. let json = r#"{ "data": [ { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4" }, { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4-thinking", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4 (Thinking)", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4-thinking" } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); // Both models should be preserved even though they share the same family assert_eq!(schema.data.len(), 2); assert_eq!(schema.data[0].id, "claude-sonnet-4"); assert_eq!(schema.data[1].id, "claude-sonnet-4-thinking"); } #[test] fn test_mixed_vendor_models_all_preserved() { // Test that models from different vendors are all preserved. let json = r#"{ "data": [ { "billing": { "is_premium": false, "multiplier": 1 }, "capabilities": { "family": "gpt-4o", "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "gpt-4o", "is_chat_default": true, "is_chat_fallback": false, "model_picker_enabled": true, "name": "GPT-4o", "object": "model", "preview": false, "vendor": "Azure OpenAI", "version": "gpt-4o" }, { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4" }, { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "gemini-2.0-flash", "limits": { "max_context_window_tokens": 1000000, "max_output_tokens": 8192, "max_prompt_tokens": 900000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "gemini-2.0-flash", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Gemini 2.0 Flash", "object": "model", "preview": false, "vendor": "Google", "version": "gemini-2.0-flash" } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); // All three models from different vendors should be preserved assert_eq!(schema.data.len(), 3); assert_eq!(schema.data[0].id, "gpt-4o"); assert_eq!(schema.data[1].id, "claude-sonnet-4"); assert_eq!(schema.data[2].id, "gemini-2.0-flash"); } #[test] fn test_model_with_messages_endpoint_deserializes() { // Anthropic Claude models use /v1/messages endpoint. // This test verifies such models deserialize correctly (issue #47540 root cause). let json = r#"{ "data": [ { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "claude-sonnet-4", "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "claude-sonnet-4", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude Sonnet 4", "object": "model", "preview": false, "vendor": "Anthropic", "version": "claude-sonnet-4", "supported_endpoints": ["/v1/messages"] } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); assert_eq!(schema.data.len(), 1); assert_eq!(schema.data[0].id, "claude-sonnet-4"); assert_eq!( schema.data[0].supported_endpoints, vec![ModelSupportedEndpoint::Messages] ); } #[test] fn test_model_with_unknown_endpoint_deserializes() { // Future-proofing: unknown endpoints should deserialize to Unknown variant // instead of causing the entire model to fail deserialization. let json = r#"{ "data": [ { "billing": { "is_premium": false, "multiplier": 1 }, "capabilities": { "family": "future-model", "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 8192, "max_prompt_tokens": 120000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "future-model-v2", "is_chat_default": false, "is_chat_fallback": false, "model_picker_enabled": true, "name": "Future Model v2", "object": "model", "preview": false, "vendor": "OpenAI", "version": "v2.0", "supported_endpoints": ["/v2/completions", "/chat/completions"] } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); assert_eq!(schema.data.len(), 1); assert_eq!(schema.data[0].id, "future-model-v2"); assert_eq!( schema.data[0].supported_endpoints, vec![ ModelSupportedEndpoint::Unknown, ModelSupportedEndpoint::ChatCompletions ] ); } #[test] fn test_model_with_multiple_endpoints() { // Test model with multiple supported endpoints (common for newer models). let json = r#"{ "data": [ { "billing": { "is_premium": true, "multiplier": 1 }, "capabilities": { "family": "gpt-4o", "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 }, "object": "model_capabilities", "supports": { "streaming": true, "tool_calls": true }, "type": "chat" }, "id": "gpt-4o", "is_chat_default": true, "is_chat_fallback": false, "model_picker_enabled": true, "name": "GPT-4o", "object": "model", "preview": false, "vendor": "OpenAI", "version": "gpt-4o", "supported_endpoints": ["/chat/completions", "/responses"] } ], "object": "list" }"#; let schema: ModelSchema = serde_json::from_str(json).unwrap(); assert_eq!(schema.data.len(), 1); assert_eq!(schema.data[0].id, "gpt-4o"); assert_eq!( schema.data[0].supported_endpoints, vec![ ModelSupportedEndpoint::ChatCompletions, ModelSupportedEndpoint::Responses ] ); } #[test] fn test_supports_response_method() { // Test the supports_response() method which determines endpoint routing. let model_with_responses_only = Model { billing: ModelBilling { is_premium: false, multiplier: 1.0, restricted_to: None, }, capabilities: ModelCapabilities { family: "test".to_string(), limits: ModelLimits::default(), supports: ModelSupportedFeatures { streaming: true, tool_calls: true, parallel_tool_calls: false, vision: false, thinking: false, adaptive_thinking: false, max_thinking_budget: None, min_thinking_budget: None, reasoning_effort: vec![], }, model_type: "chat".to_string(), tokenizer: None, }, id: "test-model".to_string(), name: "Test Model".to_string(), policy: None, vendor: ModelVendor::OpenAI, is_chat_default: false, is_chat_fallback: false, model_picker_enabled: true, supported_endpoints: vec![ModelSupportedEndpoint::Responses], }; let model_with_chat_completions = Model { supported_endpoints: vec![ModelSupportedEndpoint::ChatCompletions], ..model_with_responses_only.clone() }; let model_with_both = Model { supported_endpoints: vec![ ModelSupportedEndpoint::ChatCompletions, ModelSupportedEndpoint::Responses, ], ..model_with_responses_only.clone() }; let model_with_messages = Model { supported_endpoints: vec![ModelSupportedEndpoint::Messages], ..model_with_responses_only.clone() }; // Only /responses endpoint -> supports_response = true assert!(model_with_responses_only.supports_response()); // Only /chat/completions endpoint -> supports_response = false assert!(!model_with_chat_completions.supports_response()); // Both endpoints (has /chat/completions) -> supports_response = false assert!(model_with_both.supports_response()); // Only /v1/messages endpoint -> supports_response = false (doesn't have /responses) assert!(!model_with_messages.supports_response()); } #[test] fn test_tool_choice_required_serializes_as_required() { // Regression test: ToolChoice::Required must serialize as "required" (not "any") // for OpenAI-compatible APIs. Reverting the rename would break this. assert_eq!( serde_json::to_string(&ToolChoice::Required).unwrap(), "\"required\"" ); assert_eq!( serde_json::to_string(&ToolChoice::Auto).unwrap(), "\"auto\"" ); assert_eq!( serde_json::to_string(&ToolChoice::None).unwrap(), "\"none\"" ); } }