1pub mod responses;
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::OnceLock;
6
7use anyhow::Context as _;
8use anyhow::{Result, anyhow};
9use collections::HashSet;
10use fs::Fs;
11use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
12use gpui::WeakEntity;
13use gpui::{App, AsyncApp, Global, prelude::*};
14use http_client::HttpRequestExt;
15use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
16use paths::home_dir;
17use serde::{Deserialize, Serialize};
18
19use settings::watch_config_dir;
20
21pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN";
22const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com";
23
24#[derive(Default, Clone, Debug, PartialEq)]
25pub struct CopilotChatConfiguration {
26 pub enterprise_uri: Option<String>,
27}
28
29impl CopilotChatConfiguration {
30 pub fn oauth_domain(&self) -> String {
31 if let Some(enterprise_uri) = &self.enterprise_uri {
32 Self::parse_domain(enterprise_uri)
33 } else {
34 "github.com".to_string()
35 }
36 }
37
38 pub fn graphql_url(&self) -> String {
39 if let Some(enterprise_uri) = &self.enterprise_uri {
40 let domain = Self::parse_domain(enterprise_uri);
41 format!("https://{}/api/graphql", domain)
42 } else {
43 "https://api.github.com/graphql".to_string()
44 }
45 }
46
47 pub fn chat_completions_url(&self, api_endpoint: &str) -> String {
48 format!("{}/chat/completions", api_endpoint)
49 }
50
51 pub fn responses_url(&self, api_endpoint: &str) -> String {
52 format!("{}/responses", api_endpoint)
53 }
54
55 pub fn messages_url(&self, api_endpoint: &str) -> String {
56 format!("{}/v1/messages", api_endpoint)
57 }
58
59 pub fn models_url(&self, api_endpoint: &str) -> String {
60 format!("{}/models", api_endpoint)
61 }
62
63 fn parse_domain(enterprise_uri: &str) -> String {
64 let uri = enterprise_uri.trim_end_matches('/');
65
66 if let Some(domain) = uri.strip_prefix("https://") {
67 domain.split('/').next().unwrap_or(domain).to_string()
68 } else if let Some(domain) = uri.strip_prefix("http://") {
69 domain.split('/').next().unwrap_or(domain).to_string()
70 } else {
71 uri.split('/').next().unwrap_or(uri).to_string()
72 }
73 }
74}
75
76#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
77#[serde(rename_all = "lowercase")]
78pub enum Role {
79 User,
80 Assistant,
81 System,
82}
83
84#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
85pub enum ChatLocation {
86 #[default]
87 Panel,
88 Editor,
89 EditingSession,
90 Terminal,
91 Agent,
92 Other,
93}
94
95impl ChatLocation {
96 pub fn to_intent_string(self) -> &'static str {
97 match self {
98 ChatLocation::Panel => "conversation-panel",
99 ChatLocation::Editor => "conversation-inline",
100 ChatLocation::EditingSession => "conversation-edits",
101 ChatLocation::Terminal => "conversation-terminal",
102 ChatLocation::Agent => "conversation-agent",
103 ChatLocation::Other => "conversation-other",
104 }
105 }
106}
107
108#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
109pub enum ModelSupportedEndpoint {
110 #[serde(rename = "/chat/completions")]
111 ChatCompletions,
112 #[serde(rename = "/responses")]
113 Responses,
114 #[serde(rename = "/v1/messages")]
115 Messages,
116 /// Unknown endpoint that we don't explicitly support yet
117 #[serde(other)]
118 Unknown,
119}
120
121#[derive(Deserialize)]
122struct ModelSchema {
123 #[serde(deserialize_with = "deserialize_models_skip_errors")]
124 data: Vec<Model>,
125}
126
127fn deserialize_models_skip_errors<'de, D>(deserializer: D) -> Result<Vec<Model>, D::Error>
128where
129 D: serde::Deserializer<'de>,
130{
131 let raw_values = Vec::<serde_json::Value>::deserialize(deserializer)?;
132 let models = raw_values
133 .into_iter()
134 .filter_map(|value| match serde_json::from_value::<Model>(value) {
135 Ok(model) => Some(model),
136 Err(err) => {
137 log::warn!("GitHub Copilot Chat model failed to deserialize: {:?}", err);
138 None
139 }
140 })
141 .collect();
142
143 Ok(models)
144}
145
146#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
147pub struct Model {
148 billing: ModelBilling,
149 capabilities: ModelCapabilities,
150 id: String,
151 name: String,
152 policy: Option<ModelPolicy>,
153 vendor: ModelVendor,
154 is_chat_default: bool,
155 // The model with this value true is selected by VSCode copilot if a premium request limit is
156 // reached. Zed does not currently implement this behaviour
157 is_chat_fallback: bool,
158 model_picker_enabled: bool,
159 #[serde(default)]
160 supported_endpoints: Vec<ModelSupportedEndpoint>,
161}
162
163#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
164struct ModelBilling {
165 is_premium: bool,
166 multiplier: f64,
167 // List of plans a model is restricted to
168 // Field is not present if a model is available for all plans
169 #[serde(default)]
170 restricted_to: Option<Vec<String>>,
171}
172
173#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
174struct ModelCapabilities {
175 family: String,
176 #[serde(default)]
177 limits: ModelLimits,
178 supports: ModelSupportedFeatures,
179 #[serde(rename = "type")]
180 model_type: String,
181 #[serde(default)]
182 tokenizer: Option<String>,
183}
184
185#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
186struct ModelLimits {
187 #[serde(default)]
188 max_context_window_tokens: usize,
189 #[serde(default)]
190 max_output_tokens: usize,
191 #[serde(default)]
192 max_prompt_tokens: u64,
193}
194
195#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
196struct ModelPolicy {
197 state: String,
198}
199
200#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
201struct ModelSupportedFeatures {
202 #[serde(default)]
203 streaming: bool,
204 #[serde(default)]
205 tool_calls: bool,
206 #[serde(default)]
207 parallel_tool_calls: bool,
208 #[serde(default)]
209 vision: bool,
210 #[serde(default)]
211 thinking: bool,
212 #[serde(default)]
213 adaptive_thinking: bool,
214 #[serde(default)]
215 max_thinking_budget: Option<u32>,
216 #[serde(default)]
217 min_thinking_budget: Option<u32>,
218 #[serde(default)]
219 reasoning_effort: Vec<String>,
220}
221
222#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
223pub enum ModelVendor {
224 // Azure OpenAI should have no functional difference from OpenAI in Copilot Chat
225 #[serde(alias = "Azure OpenAI")]
226 OpenAI,
227 Google,
228 Anthropic,
229 #[serde(rename = "xAI")]
230 XAI,
231 /// Unknown vendor that we don't explicitly support yet
232 #[serde(other)]
233 Unknown,
234}
235
236#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
237#[serde(tag = "type")]
238pub enum ChatMessagePart {
239 #[serde(rename = "text")]
240 Text { text: String },
241 #[serde(rename = "image_url")]
242 Image { image_url: ImageUrl },
243}
244
245#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
246pub struct ImageUrl {
247 pub url: String,
248}
249
250impl Model {
251 pub fn uses_streaming(&self) -> bool {
252 self.capabilities.supports.streaming
253 }
254
255 pub fn id(&self) -> &str {
256 self.id.as_str()
257 }
258
259 pub fn display_name(&self) -> &str {
260 self.name.as_str()
261 }
262
263 pub fn max_token_count(&self) -> u64 {
264 self.capabilities.limits.max_context_window_tokens as u64
265 }
266
267 pub fn max_output_tokens(&self) -> usize {
268 self.capabilities.limits.max_output_tokens
269 }
270
271 pub fn supports_tools(&self) -> bool {
272 self.capabilities.supports.tool_calls
273 }
274
275 pub fn vendor(&self) -> ModelVendor {
276 self.vendor
277 }
278
279 pub fn supports_vision(&self) -> bool {
280 self.capabilities.supports.vision
281 }
282
283 pub fn supports_parallel_tool_calls(&self) -> bool {
284 self.capabilities.supports.parallel_tool_calls
285 }
286
287 pub fn tokenizer(&self) -> Option<&str> {
288 self.capabilities.tokenizer.as_deref()
289 }
290
291 pub fn supports_response(&self) -> bool {
292 self.supported_endpoints.len() > 0
293 && !self
294 .supported_endpoints
295 .contains(&ModelSupportedEndpoint::ChatCompletions)
296 && self
297 .supported_endpoints
298 .contains(&ModelSupportedEndpoint::Responses)
299 }
300
301 pub fn supports_messages(&self) -> bool {
302 self.supported_endpoints
303 .contains(&ModelSupportedEndpoint::Messages)
304 }
305
306 pub fn supports_thinking(&self) -> bool {
307 self.capabilities.supports.thinking
308 }
309
310 pub fn supports_adaptive_thinking(&self) -> bool {
311 self.capabilities.supports.adaptive_thinking
312 }
313
314 pub fn can_think(&self) -> bool {
315 self.supports_thinking()
316 || self.supports_adaptive_thinking()
317 || self.max_thinking_budget().is_some()
318 }
319
320 pub fn max_thinking_budget(&self) -> Option<u32> {
321 self.capabilities.supports.max_thinking_budget
322 }
323
324 pub fn min_thinking_budget(&self) -> Option<u32> {
325 self.capabilities.supports.min_thinking_budget
326 }
327
328 pub fn reasoning_effort_levels(&self) -> &[String] {
329 &self.capabilities.supports.reasoning_effort
330 }
331
332 pub fn family(&self) -> &str {
333 &self.capabilities.family
334 }
335
336 pub fn multiplier(&self) -> f64 {
337 self.billing.multiplier
338 }
339}
340
341#[derive(Serialize, Deserialize)]
342pub struct Request {
343 pub n: usize,
344 pub stream: bool,
345 pub temperature: f32,
346 pub model: String,
347 pub messages: Vec<ChatMessage>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub tools: Vec<Tool>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub tool_choice: Option<ToolChoice>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub thinking_budget: Option<u32>,
354}
355
356#[derive(Serialize, Deserialize)]
357pub struct Function {
358 pub name: String,
359 pub description: String,
360 pub parameters: serde_json::Value,
361}
362
363#[derive(Serialize, Deserialize)]
364#[serde(tag = "type", rename_all = "snake_case")]
365pub enum Tool {
366 Function { function: Function },
367}
368
369#[derive(Serialize, Deserialize, Debug)]
370#[serde(rename_all = "lowercase")]
371pub enum ToolChoice {
372 Auto,
373 Any,
374 None,
375}
376
377#[derive(Serialize, Deserialize, Debug)]
378#[serde(tag = "role", rename_all = "lowercase")]
379pub enum ChatMessage {
380 Assistant {
381 content: ChatMessageContent,
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
383 tool_calls: Vec<ToolCall>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 reasoning_opaque: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 reasoning_text: Option<String>,
388 },
389 User {
390 content: ChatMessageContent,
391 },
392 System {
393 content: String,
394 },
395 Tool {
396 content: ChatMessageContent,
397 tool_call_id: String,
398 },
399}
400
401#[derive(Debug, Serialize, Deserialize)]
402#[serde(untagged)]
403pub enum ChatMessageContent {
404 Plain(String),
405 Multipart(Vec<ChatMessagePart>),
406}
407
408impl ChatMessageContent {
409 pub fn empty() -> Self {
410 ChatMessageContent::Multipart(vec![])
411 }
412}
413
414impl From<Vec<ChatMessagePart>> for ChatMessageContent {
415 fn from(mut parts: Vec<ChatMessagePart>) -> Self {
416 if let [ChatMessagePart::Text { text }] = parts.as_mut_slice() {
417 ChatMessageContent::Plain(std::mem::take(text))
418 } else {
419 ChatMessageContent::Multipart(parts)
420 }
421 }
422}
423
424impl From<String> for ChatMessageContent {
425 fn from(text: String) -> Self {
426 ChatMessageContent::Plain(text)
427 }
428}
429
430#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
431pub struct ToolCall {
432 pub id: String,
433 #[serde(flatten)]
434 pub content: ToolCallContent,
435}
436
437#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
438#[serde(tag = "type", rename_all = "lowercase")]
439pub enum ToolCallContent {
440 Function { function: FunctionContent },
441}
442
443#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
444pub struct FunctionContent {
445 pub name: String,
446 pub arguments: String,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub thought_signature: Option<String>,
449}
450
451#[derive(Deserialize, Debug)]
452#[serde(tag = "type", rename_all = "snake_case")]
453pub struct ResponseEvent {
454 pub choices: Vec<ResponseChoice>,
455 pub id: String,
456 pub usage: Option<Usage>,
457}
458
459#[derive(Deserialize, Debug)]
460pub struct Usage {
461 pub completion_tokens: u64,
462 pub prompt_tokens: u64,
463 pub total_tokens: u64,
464}
465
466#[derive(Debug, Deserialize)]
467pub struct ResponseChoice {
468 pub index: Option<usize>,
469 pub finish_reason: Option<String>,
470 pub delta: Option<ResponseDelta>,
471 pub message: Option<ResponseDelta>,
472}
473
474#[derive(Debug, Deserialize)]
475pub struct ResponseDelta {
476 pub content: Option<String>,
477 pub role: Option<Role>,
478 #[serde(default)]
479 pub tool_calls: Vec<ToolCallChunk>,
480 pub reasoning_opaque: Option<String>,
481 pub reasoning_text: Option<String>,
482}
483#[derive(Deserialize, Debug, Eq, PartialEq)]
484pub struct ToolCallChunk {
485 pub index: Option<usize>,
486 pub id: Option<String>,
487 pub function: Option<FunctionChunk>,
488}
489
490#[derive(Deserialize, Debug, Eq, PartialEq)]
491pub struct FunctionChunk {
492 pub name: Option<String>,
493 pub arguments: Option<String>,
494 pub thought_signature: Option<String>,
495}
496
497struct GlobalCopilotChat(gpui::Entity<CopilotChat>);
498
499impl Global for GlobalCopilotChat {}
500
501pub struct CopilotChat {
502 oauth_token: Option<String>,
503 api_endpoint: Option<String>,
504 configuration: CopilotChatConfiguration,
505 models: Option<Vec<Model>>,
506 client: Arc<dyn HttpClient>,
507}
508
509pub fn init(
510 fs: Arc<dyn Fs>,
511 client: Arc<dyn HttpClient>,
512 configuration: CopilotChatConfiguration,
513 cx: &mut App,
514) {
515 let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, configuration, cx));
516 cx.set_global(GlobalCopilotChat(copilot_chat));
517}
518
519pub fn copilot_chat_config_dir() -> &'static PathBuf {
520 static COPILOT_CHAT_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
521
522 COPILOT_CHAT_CONFIG_DIR.get_or_init(|| {
523 let config_dir = if cfg!(target_os = "windows") {
524 dirs::data_local_dir().expect("failed to determine LocalAppData directory")
525 } else {
526 std::env::var("XDG_CONFIG_HOME")
527 .map(PathBuf::from)
528 .unwrap_or_else(|_| home_dir().join(".config"))
529 };
530
531 config_dir.join("github-copilot")
532 })
533}
534
535fn copilot_chat_config_paths() -> [PathBuf; 2] {
536 let base_dir = copilot_chat_config_dir();
537 [base_dir.join("hosts.json"), base_dir.join("apps.json")]
538}
539
540impl CopilotChat {
541 pub fn global(cx: &App) -> Option<gpui::Entity<Self>> {
542 cx.try_global::<GlobalCopilotChat>()
543 .map(|model| model.0.clone())
544 }
545
546 fn new(
547 fs: Arc<dyn Fs>,
548 client: Arc<dyn HttpClient>,
549 configuration: CopilotChatConfiguration,
550 cx: &mut Context<Self>,
551 ) -> Self {
552 let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
553 let dir_path = copilot_chat_config_dir();
554
555 cx.spawn(async move |this, cx| {
556 let mut parent_watch_rx = watch_config_dir(
557 cx.background_executor(),
558 fs.clone(),
559 dir_path.clone(),
560 config_paths,
561 );
562 while let Some(contents) = parent_watch_rx.next().await {
563 let oauth_domain =
564 this.read_with(cx, |this, _| this.configuration.oauth_domain())?;
565 let oauth_token = extract_oauth_token(contents, &oauth_domain);
566
567 this.update(cx, |this, cx| {
568 this.oauth_token = oauth_token.clone();
569 cx.notify();
570 })?;
571
572 if oauth_token.is_some() {
573 Self::update_models(&this, cx).await?;
574 }
575 }
576 anyhow::Ok(())
577 })
578 .detach_and_log_err(cx);
579
580 let this = Self {
581 oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(),
582 api_endpoint: None,
583 models: None,
584 configuration,
585 client,
586 };
587
588 if this.oauth_token.is_some() {
589 cx.spawn(async move |this, cx| Self::update_models(&this, cx).await)
590 .detach_and_log_err(cx);
591 }
592
593 this
594 }
595
596 async fn update_models(this: &WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
597 let (oauth_token, client, configuration) = this.read_with(cx, |this, _| {
598 (
599 this.oauth_token.clone(),
600 this.client.clone(),
601 this.configuration.clone(),
602 )
603 })?;
604
605 let oauth_token = oauth_token
606 .ok_or_else(|| anyhow!("OAuth token is missing while updating Copilot Chat models"))?;
607
608 let api_endpoint =
609 Self::resolve_api_endpoint(&this, &oauth_token, &configuration, &client, cx).await?;
610
611 let models_url = configuration.models_url(&api_endpoint);
612 let models = get_models(models_url.into(), oauth_token, client.clone()).await?;
613
614 this.update(cx, |this, cx| {
615 this.models = Some(models);
616 cx.notify();
617 })?;
618 anyhow::Ok(())
619 }
620
621 pub fn is_authenticated(&self) -> bool {
622 self.oauth_token.is_some()
623 }
624
625 pub fn models(&self) -> Option<&[Model]> {
626 self.models.as_deref()
627 }
628
629 pub async fn stream_completion(
630 request: Request,
631 location: ChatLocation,
632 is_user_initiated: bool,
633 mut cx: AsyncApp,
634 ) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
635 let (client, oauth_token, api_endpoint, configuration) =
636 Self::get_auth_details(&mut cx).await?;
637
638 let api_url = configuration.chat_completions_url(&api_endpoint);
639 stream_completion(
640 client.clone(),
641 oauth_token,
642 api_url.into(),
643 request,
644 is_user_initiated,
645 location,
646 )
647 .await
648 }
649
650 pub async fn stream_response(
651 request: responses::Request,
652 location: ChatLocation,
653 is_user_initiated: bool,
654 mut cx: AsyncApp,
655 ) -> Result<BoxStream<'static, Result<responses::StreamEvent>>> {
656 let (client, oauth_token, api_endpoint, configuration) =
657 Self::get_auth_details(&mut cx).await?;
658
659 let api_url = configuration.responses_url(&api_endpoint);
660 responses::stream_response(
661 client.clone(),
662 oauth_token,
663 api_url,
664 request,
665 is_user_initiated,
666 location,
667 )
668 .await
669 }
670
671 pub async fn stream_messages(
672 body: String,
673 location: ChatLocation,
674 is_user_initiated: bool,
675 anthropic_beta: Option<String>,
676 mut cx: AsyncApp,
677 ) -> Result<BoxStream<'static, Result<anthropic::Event, anthropic::AnthropicError>>> {
678 let (client, oauth_token, api_endpoint, configuration) =
679 Self::get_auth_details(&mut cx).await?;
680
681 let api_url = configuration.messages_url(&api_endpoint);
682 stream_messages(
683 client.clone(),
684 oauth_token,
685 api_url,
686 body,
687 is_user_initiated,
688 location,
689 anthropic_beta,
690 )
691 .await
692 }
693
694 async fn get_auth_details(
695 cx: &mut AsyncApp,
696 ) -> Result<(
697 Arc<dyn HttpClient>,
698 String,
699 String,
700 CopilotChatConfiguration,
701 )> {
702 let this = cx
703 .update(|cx| Self::global(cx))
704 .context("Copilot chat is not enabled")?;
705
706 let (oauth_token, api_endpoint, client, configuration) = this.read_with(cx, |this, _| {
707 (
708 this.oauth_token.clone(),
709 this.api_endpoint.clone(),
710 this.client.clone(),
711 this.configuration.clone(),
712 )
713 });
714
715 let oauth_token = oauth_token.context("No OAuth token available")?;
716
717 let api_endpoint = match api_endpoint {
718 Some(endpoint) => endpoint,
719 None => {
720 let weak = this.downgrade();
721 Self::resolve_api_endpoint(&weak, &oauth_token, &configuration, &client, cx).await?
722 }
723 };
724
725 Ok((client, oauth_token, api_endpoint, configuration))
726 }
727
728 async fn resolve_api_endpoint(
729 this: &WeakEntity<Self>,
730 oauth_token: &str,
731 configuration: &CopilotChatConfiguration,
732 client: &Arc<dyn HttpClient>,
733 cx: &mut AsyncApp,
734 ) -> Result<String> {
735 let api_endpoint = match discover_api_endpoint(oauth_token, configuration, client).await {
736 Ok(endpoint) => endpoint,
737 Err(error) => {
738 log::warn!(
739 "Failed to discover Copilot API endpoint via GraphQL, \
740 falling back to {DEFAULT_COPILOT_API_ENDPOINT}: {error:#}"
741 );
742 DEFAULT_COPILOT_API_ENDPOINT.to_string()
743 }
744 };
745
746 this.update(cx, |this, cx| {
747 this.api_endpoint = Some(api_endpoint.clone());
748 cx.notify();
749 })?;
750
751 Ok(api_endpoint)
752 }
753
754 pub fn set_configuration(
755 &mut self,
756 configuration: CopilotChatConfiguration,
757 cx: &mut Context<Self>,
758 ) {
759 let same_configuration = self.configuration == configuration;
760 self.configuration = configuration;
761 if !same_configuration {
762 self.api_endpoint = None;
763 cx.spawn(async move |this, cx| {
764 Self::update_models(&this, cx).await?;
765 Ok::<_, anyhow::Error>(())
766 })
767 .detach();
768 }
769 }
770}
771
772async fn get_models(
773 models_url: Arc<str>,
774 oauth_token: String,
775 client: Arc<dyn HttpClient>,
776) -> Result<Vec<Model>> {
777 let all_models = request_models(models_url, oauth_token, client).await?;
778
779 let mut models: Vec<Model> = all_models
780 .into_iter()
781 .filter(|model| {
782 model.model_picker_enabled
783 && model.capabilities.model_type.as_str() == "chat"
784 && model
785 .policy
786 .as_ref()
787 .is_none_or(|policy| policy.state == "enabled")
788 })
789 .collect();
790
791 if let Some(default_model_position) = models.iter().position(|model| model.is_chat_default) {
792 let default_model = models.remove(default_model_position);
793 models.insert(0, default_model);
794 }
795
796 Ok(models)
797}
798
799#[derive(Deserialize)]
800struct GraphQLResponse {
801 data: Option<GraphQLData>,
802}
803
804#[derive(Deserialize)]
805struct GraphQLData {
806 viewer: GraphQLViewer,
807}
808
809#[derive(Deserialize)]
810struct GraphQLViewer {
811 #[serde(rename = "copilotEndpoints")]
812 copilot_endpoints: GraphQLCopilotEndpoints,
813}
814
815#[derive(Deserialize)]
816struct GraphQLCopilotEndpoints {
817 api: String,
818}
819
820pub(crate) async fn discover_api_endpoint(
821 oauth_token: &str,
822 configuration: &CopilotChatConfiguration,
823 client: &Arc<dyn HttpClient>,
824) -> Result<String> {
825 let graphql_url = configuration.graphql_url();
826 let query = serde_json::json!({
827 "query": "query { viewer { copilotEndpoints { api } } }"
828 });
829
830 let request = HttpRequest::builder()
831 .method(Method::POST)
832 .uri(graphql_url.as_str())
833 .header("Authorization", format!("Bearer {}", oauth_token))
834 .header("Content-Type", "application/json")
835 .body(AsyncBody::from(serde_json::to_string(&query)?))?;
836
837 let mut response = client.send(request).await?;
838
839 anyhow::ensure!(
840 response.status().is_success(),
841 "GraphQL endpoint discovery failed: {}",
842 response.status()
843 );
844
845 let mut body = Vec::new();
846 response.body_mut().read_to_end(&mut body).await?;
847 let body_str = std::str::from_utf8(&body)?;
848
849 let parsed: GraphQLResponse = serde_json::from_str(body_str)
850 .context("Failed to parse GraphQL response for Copilot endpoint discovery")?;
851
852 let data = parsed
853 .data
854 .context("GraphQL response contained no data field")?;
855
856 Ok(data.viewer.copilot_endpoints.api)
857}
858
859pub(crate) fn copilot_request_headers(
860 builder: http_client::Builder,
861 oauth_token: &str,
862 is_user_initiated: Option<bool>,
863 location: Option<ChatLocation>,
864) -> http_client::Builder {
865 builder
866 .header("Authorization", format!("Bearer {}", oauth_token))
867 .header("Content-Type", "application/json")
868 .header(
869 "Editor-Version",
870 format!(
871 "Zed/{}",
872 option_env!("CARGO_PKG_VERSION").unwrap_or("unknown")
873 ),
874 )
875 .header("X-GitHub-Api-Version", "2025-10-01")
876 .when_some(is_user_initiated, |builder, is_user_initiated| {
877 builder.header(
878 "X-Initiator",
879 if is_user_initiated { "user" } else { "agent" },
880 )
881 })
882 .when_some(location, |builder, loc| {
883 let interaction_type = loc.to_intent_string();
884 builder
885 .header("X-Interaction-Type", interaction_type)
886 .header("OpenAI-Intent", interaction_type)
887 })
888}
889
890async fn request_models(
891 models_url: Arc<str>,
892 oauth_token: String,
893 client: Arc<dyn HttpClient>,
894) -> Result<Vec<Model>> {
895 let request_builder = copilot_request_headers(
896 HttpRequest::builder()
897 .method(Method::GET)
898 .uri(models_url.as_ref()),
899 &oauth_token,
900 None,
901 None,
902 );
903
904 let request = request_builder.body(AsyncBody::empty())?;
905
906 let mut response = client.send(request).await?;
907
908 anyhow::ensure!(
909 response.status().is_success(),
910 "Failed to request models: {}",
911 response.status()
912 );
913 let mut body = Vec::new();
914 response.body_mut().read_to_end(&mut body).await?;
915
916 let body_str = std::str::from_utf8(&body)?;
917
918 let models = serde_json::from_str::<ModelSchema>(body_str)?.data;
919
920 Ok(models)
921}
922
923fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
924 serde_json::from_str::<serde_json::Value>(&contents)
925 .map(|v| {
926 v.as_object().and_then(|obj| {
927 obj.iter().find_map(|(key, value)| {
928 if key.starts_with(domain) {
929 value["oauth_token"].as_str().map(|v| v.to_string())
930 } else {
931 None
932 }
933 })
934 })
935 })
936 .ok()
937 .flatten()
938}
939
940async fn stream_completion(
941 client: Arc<dyn HttpClient>,
942 oauth_token: String,
943 completion_url: Arc<str>,
944 request: Request,
945 is_user_initiated: bool,
946 location: ChatLocation,
947) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
948 let is_vision_request = request.messages.iter().any(|message| match message {
949 ChatMessage::User { content }
950 | ChatMessage::Assistant { content, .. }
951 | ChatMessage::Tool { content, .. } => {
952 matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. })))
953 }
954 _ => false,
955 });
956
957 let request_builder = copilot_request_headers(
958 HttpRequest::builder()
959 .method(Method::POST)
960 .uri(completion_url.as_ref()),
961 &oauth_token,
962 Some(is_user_initiated),
963 Some(location),
964 )
965 .when(is_vision_request, |builder| {
966 builder.header("Copilot-Vision-Request", is_vision_request.to_string())
967 });
968
969 let is_streaming = request.stream;
970
971 let json = serde_json::to_string(&request)?;
972 let request = request_builder.body(AsyncBody::from(json))?;
973 let mut response = client.send(request).await?;
974
975 if !response.status().is_success() {
976 let mut body = Vec::new();
977 response.body_mut().read_to_end(&mut body).await?;
978 let body_str = std::str::from_utf8(&body)?;
979 anyhow::bail!(
980 "Failed to connect to API: {} {}",
981 response.status(),
982 body_str
983 );
984 }
985
986 if is_streaming {
987 let reader = BufReader::new(response.into_body());
988 Ok(reader
989 .lines()
990 .filter_map(|line| async move {
991 match line {
992 Ok(line) => {
993 let line = line.strip_prefix("data: ")?;
994 if line.starts_with("[DONE]") {
995 return None;
996 }
997
998 match serde_json::from_str::<ResponseEvent>(line) {
999 Ok(response) => {
1000 if response.choices.is_empty() {
1001 None
1002 } else {
1003 Some(Ok(response))
1004 }
1005 }
1006 Err(error) => Some(Err(anyhow!(error))),
1007 }
1008 }
1009 Err(error) => Some(Err(anyhow!(error))),
1010 }
1011 })
1012 .boxed())
1013 } else {
1014 let mut body = Vec::new();
1015 response.body_mut().read_to_end(&mut body).await?;
1016 let body_str = std::str::from_utf8(&body)?;
1017 let response: ResponseEvent = serde_json::from_str(body_str)?;
1018
1019 Ok(futures::stream::once(async move { Ok(response) }).boxed())
1020 }
1021}
1022
1023async fn stream_messages(
1024 client: Arc<dyn HttpClient>,
1025 oauth_token: String,
1026 api_url: String,
1027 body: String,
1028 is_user_initiated: bool,
1029 location: ChatLocation,
1030 anthropic_beta: Option<String>,
1031) -> Result<BoxStream<'static, Result<anthropic::Event, anthropic::AnthropicError>>> {
1032 let mut request_builder = copilot_request_headers(
1033 HttpRequest::builder().method(Method::POST).uri(&api_url),
1034 &oauth_token,
1035 Some(is_user_initiated),
1036 Some(location),
1037 );
1038
1039 if let Some(beta) = &anthropic_beta {
1040 request_builder = request_builder.header("anthropic-beta", beta.as_str());
1041 }
1042
1043 let request = request_builder.body(AsyncBody::from(body))?;
1044 let mut response = client.send(request).await?;
1045
1046 if !response.status().is_success() {
1047 let mut body = String::new();
1048 response.body_mut().read_to_string(&mut body).await?;
1049 anyhow::bail!("Failed to connect to API: {} {}", response.status(), body);
1050 }
1051
1052 let reader = BufReader::new(response.into_body());
1053 Ok(reader
1054 .lines()
1055 .filter_map(|line| async move {
1056 match line {
1057 Ok(line) => {
1058 let line = line
1059 .strip_prefix("data: ")
1060 .or_else(|| line.strip_prefix("data:"))?;
1061 if line.starts_with("[DONE]") || line.is_empty() {
1062 return None;
1063 }
1064 match serde_json::from_str(line) {
1065 Ok(event) => Some(Ok(event)),
1066 Err(error) => {
1067 log::error!(
1068 "Failed to parse Copilot messages stream event: `{}`\nResponse: `{}`",
1069 error,
1070 line,
1071 );
1072 Some(Err(anthropic::AnthropicError::DeserializeResponse(error)))
1073 }
1074 }
1075 }
1076 Err(error) => Some(Err(anthropic::AnthropicError::ReadResponse(error))),
1077 }
1078 })
1079 .boxed())
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1087 fn test_resilient_model_schema_deserialize() {
1088 let json = r#"{
1089 "data": [
1090 {
1091 "billing": {
1092 "is_premium": false,
1093 "multiplier": 0
1094 },
1095 "capabilities": {
1096 "family": "gpt-4",
1097 "limits": {
1098 "max_context_window_tokens": 32768,
1099 "max_output_tokens": 4096,
1100 "max_prompt_tokens": 32768
1101 },
1102 "object": "model_capabilities",
1103 "supports": { "streaming": true, "tool_calls": true },
1104 "tokenizer": "cl100k_base",
1105 "type": "chat"
1106 },
1107 "id": "gpt-4",
1108 "is_chat_default": false,
1109 "is_chat_fallback": false,
1110 "model_picker_enabled": false,
1111 "name": "GPT 4",
1112 "object": "model",
1113 "preview": false,
1114 "vendor": "Azure OpenAI",
1115 "version": "gpt-4-0613"
1116 },
1117 {
1118 "some-unknown-field": 123
1119 },
1120 {
1121 "billing": {
1122 "is_premium": true,
1123 "multiplier": 1,
1124 "restricted_to": [
1125 "pro",
1126 "pro_plus",
1127 "business",
1128 "enterprise"
1129 ]
1130 },
1131 "capabilities": {
1132 "family": "claude-3.7-sonnet",
1133 "limits": {
1134 "max_context_window_tokens": 200000,
1135 "max_output_tokens": 16384,
1136 "max_prompt_tokens": 90000,
1137 "vision": {
1138 "max_prompt_image_size": 3145728,
1139 "max_prompt_images": 1,
1140 "supported_media_types": ["image/jpeg", "image/png", "image/webp"]
1141 }
1142 },
1143 "object": "model_capabilities",
1144 "supports": {
1145 "parallel_tool_calls": true,
1146 "streaming": true,
1147 "tool_calls": true,
1148 "vision": true
1149 },
1150 "tokenizer": "o200k_base",
1151 "type": "chat"
1152 },
1153 "id": "claude-3.7-sonnet",
1154 "is_chat_default": false,
1155 "is_chat_fallback": false,
1156 "model_picker_enabled": true,
1157 "name": "Claude 3.7 Sonnet",
1158 "object": "model",
1159 "policy": {
1160 "state": "enabled",
1161 "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)."
1162 },
1163 "preview": false,
1164 "vendor": "Anthropic",
1165 "version": "claude-3.7-sonnet"
1166 }
1167 ],
1168 "object": "list"
1169 }"#;
1170
1171 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1172
1173 assert_eq!(schema.data.len(), 2);
1174 assert_eq!(schema.data[0].id, "gpt-4");
1175 assert_eq!(schema.data[1].id, "claude-3.7-sonnet");
1176 }
1177
1178 #[test]
1179 fn test_unknown_vendor_resilience() {
1180 let json = r#"{
1181 "data": [
1182 {
1183 "billing": {
1184 "is_premium": false,
1185 "multiplier": 1
1186 },
1187 "capabilities": {
1188 "family": "future-model",
1189 "limits": {
1190 "max_context_window_tokens": 128000,
1191 "max_output_tokens": 8192,
1192 "max_prompt_tokens": 120000
1193 },
1194 "object": "model_capabilities",
1195 "supports": { "streaming": true, "tool_calls": true },
1196 "type": "chat"
1197 },
1198 "id": "future-model-v1",
1199 "is_chat_default": false,
1200 "is_chat_fallback": false,
1201 "model_picker_enabled": true,
1202 "name": "Future Model v1",
1203 "object": "model",
1204 "preview": false,
1205 "vendor": "SomeNewVendor",
1206 "version": "v1.0"
1207 }
1208 ],
1209 "object": "list"
1210 }"#;
1211
1212 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1213
1214 assert_eq!(schema.data.len(), 1);
1215 assert_eq!(schema.data[0].id, "future-model-v1");
1216 assert_eq!(schema.data[0].vendor, ModelVendor::Unknown);
1217 }
1218
1219 #[test]
1220 fn test_max_token_count_returns_context_window_not_prompt_tokens() {
1221 let json = r#"{
1222 "data": [
1223 {
1224 "billing": { "is_premium": true, "multiplier": 1 },
1225 "capabilities": {
1226 "family": "claude-sonnet-4",
1227 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1228 "object": "model_capabilities",
1229 "supports": { "streaming": true, "tool_calls": true },
1230 "type": "chat"
1231 },
1232 "id": "claude-sonnet-4",
1233 "is_chat_default": false,
1234 "is_chat_fallback": false,
1235 "model_picker_enabled": true,
1236 "name": "Claude Sonnet 4",
1237 "object": "model",
1238 "preview": false,
1239 "vendor": "Anthropic",
1240 "version": "claude-sonnet-4"
1241 },
1242 {
1243 "billing": { "is_premium": false, "multiplier": 1 },
1244 "capabilities": {
1245 "family": "gpt-4o",
1246 "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 },
1247 "object": "model_capabilities",
1248 "supports": { "streaming": true, "tool_calls": true },
1249 "type": "chat"
1250 },
1251 "id": "gpt-4o",
1252 "is_chat_default": true,
1253 "is_chat_fallback": false,
1254 "model_picker_enabled": true,
1255 "name": "GPT-4o",
1256 "object": "model",
1257 "preview": false,
1258 "vendor": "Azure OpenAI",
1259 "version": "gpt-4o"
1260 }
1261 ],
1262 "object": "list"
1263 }"#;
1264
1265 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1266
1267 // max_token_count() should return context window (200000), not prompt tokens (90000)
1268 assert_eq!(schema.data[0].max_token_count(), 200000);
1269
1270 // GPT-4o should return 128000 (context window), not 110000 (prompt tokens)
1271 assert_eq!(schema.data[1].max_token_count(), 128000);
1272 }
1273
1274 #[test]
1275 fn test_models_with_pending_policy_deserialize() {
1276 // This test verifies that models with policy states other than "enabled"
1277 // (such as "pending" or "requires_consent") are properly deserialized.
1278 // Note: These models will be filtered out by get_models() and won't appear
1279 // in the model picker until the user enables them on GitHub.
1280 let json = r#"{
1281 "data": [
1282 {
1283 "billing": { "is_premium": true, "multiplier": 1 },
1284 "capabilities": {
1285 "family": "claude-sonnet-4",
1286 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1287 "object": "model_capabilities",
1288 "supports": { "streaming": true, "tool_calls": true },
1289 "type": "chat"
1290 },
1291 "id": "claude-sonnet-4",
1292 "is_chat_default": false,
1293 "is_chat_fallback": false,
1294 "model_picker_enabled": true,
1295 "name": "Claude Sonnet 4",
1296 "object": "model",
1297 "policy": {
1298 "state": "pending",
1299 "terms": "Enable access to Claude models from Anthropic."
1300 },
1301 "preview": false,
1302 "vendor": "Anthropic",
1303 "version": "claude-sonnet-4"
1304 },
1305 {
1306 "billing": { "is_premium": true, "multiplier": 1 },
1307 "capabilities": {
1308 "family": "claude-opus-4",
1309 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1310 "object": "model_capabilities",
1311 "supports": { "streaming": true, "tool_calls": true },
1312 "type": "chat"
1313 },
1314 "id": "claude-opus-4",
1315 "is_chat_default": false,
1316 "is_chat_fallback": false,
1317 "model_picker_enabled": true,
1318 "name": "Claude Opus 4",
1319 "object": "model",
1320 "policy": {
1321 "state": "requires_consent",
1322 "terms": "Enable access to Claude models from Anthropic."
1323 },
1324 "preview": false,
1325 "vendor": "Anthropic",
1326 "version": "claude-opus-4"
1327 }
1328 ],
1329 "object": "list"
1330 }"#;
1331
1332 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1333
1334 // Both models should deserialize successfully (filtering happens in get_models)
1335 assert_eq!(schema.data.len(), 2);
1336 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1337 assert_eq!(schema.data[1].id, "claude-opus-4");
1338 }
1339
1340 #[test]
1341 fn test_multiple_anthropic_models_preserved() {
1342 // This test verifies that multiple Claude models from Anthropic
1343 // are all preserved and not incorrectly deduplicated.
1344 // This was the root cause of issue #47540.
1345 let json = r#"{
1346 "data": [
1347 {
1348 "billing": { "is_premium": true, "multiplier": 1 },
1349 "capabilities": {
1350 "family": "claude-sonnet-4",
1351 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1352 "object": "model_capabilities",
1353 "supports": { "streaming": true, "tool_calls": true },
1354 "type": "chat"
1355 },
1356 "id": "claude-sonnet-4",
1357 "is_chat_default": false,
1358 "is_chat_fallback": false,
1359 "model_picker_enabled": true,
1360 "name": "Claude Sonnet 4",
1361 "object": "model",
1362 "preview": false,
1363 "vendor": "Anthropic",
1364 "version": "claude-sonnet-4"
1365 },
1366 {
1367 "billing": { "is_premium": true, "multiplier": 1 },
1368 "capabilities": {
1369 "family": "claude-opus-4",
1370 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1371 "object": "model_capabilities",
1372 "supports": { "streaming": true, "tool_calls": true },
1373 "type": "chat"
1374 },
1375 "id": "claude-opus-4",
1376 "is_chat_default": false,
1377 "is_chat_fallback": false,
1378 "model_picker_enabled": true,
1379 "name": "Claude Opus 4",
1380 "object": "model",
1381 "preview": false,
1382 "vendor": "Anthropic",
1383 "version": "claude-opus-4"
1384 },
1385 {
1386 "billing": { "is_premium": true, "multiplier": 1 },
1387 "capabilities": {
1388 "family": "claude-sonnet-4.5",
1389 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1390 "object": "model_capabilities",
1391 "supports": { "streaming": true, "tool_calls": true },
1392 "type": "chat"
1393 },
1394 "id": "claude-sonnet-4.5",
1395 "is_chat_default": false,
1396 "is_chat_fallback": false,
1397 "model_picker_enabled": true,
1398 "name": "Claude Sonnet 4.5",
1399 "object": "model",
1400 "preview": false,
1401 "vendor": "Anthropic",
1402 "version": "claude-sonnet-4.5"
1403 }
1404 ],
1405 "object": "list"
1406 }"#;
1407
1408 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1409
1410 // All three Anthropic models should be preserved
1411 assert_eq!(schema.data.len(), 3);
1412 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1413 assert_eq!(schema.data[1].id, "claude-opus-4");
1414 assert_eq!(schema.data[2].id, "claude-sonnet-4.5");
1415 }
1416
1417 #[test]
1418 fn test_models_with_same_family_both_preserved() {
1419 // Test that models sharing the same family (e.g., thinking variants)
1420 // are both preserved in the model list.
1421 let json = r#"{
1422 "data": [
1423 {
1424 "billing": { "is_premium": true, "multiplier": 1 },
1425 "capabilities": {
1426 "family": "claude-sonnet-4",
1427 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1428 "object": "model_capabilities",
1429 "supports": { "streaming": true, "tool_calls": true },
1430 "type": "chat"
1431 },
1432 "id": "claude-sonnet-4",
1433 "is_chat_default": false,
1434 "is_chat_fallback": false,
1435 "model_picker_enabled": true,
1436 "name": "Claude Sonnet 4",
1437 "object": "model",
1438 "preview": false,
1439 "vendor": "Anthropic",
1440 "version": "claude-sonnet-4"
1441 },
1442 {
1443 "billing": { "is_premium": true, "multiplier": 1 },
1444 "capabilities": {
1445 "family": "claude-sonnet-4",
1446 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1447 "object": "model_capabilities",
1448 "supports": { "streaming": true, "tool_calls": true },
1449 "type": "chat"
1450 },
1451 "id": "claude-sonnet-4-thinking",
1452 "is_chat_default": false,
1453 "is_chat_fallback": false,
1454 "model_picker_enabled": true,
1455 "name": "Claude Sonnet 4 (Thinking)",
1456 "object": "model",
1457 "preview": false,
1458 "vendor": "Anthropic",
1459 "version": "claude-sonnet-4-thinking"
1460 }
1461 ],
1462 "object": "list"
1463 }"#;
1464
1465 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1466
1467 // Both models should be preserved even though they share the same family
1468 assert_eq!(schema.data.len(), 2);
1469 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1470 assert_eq!(schema.data[1].id, "claude-sonnet-4-thinking");
1471 }
1472
1473 #[test]
1474 fn test_mixed_vendor_models_all_preserved() {
1475 // Test that models from different vendors are all preserved.
1476 let json = r#"{
1477 "data": [
1478 {
1479 "billing": { "is_premium": false, "multiplier": 1 },
1480 "capabilities": {
1481 "family": "gpt-4o",
1482 "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 },
1483 "object": "model_capabilities",
1484 "supports": { "streaming": true, "tool_calls": true },
1485 "type": "chat"
1486 },
1487 "id": "gpt-4o",
1488 "is_chat_default": true,
1489 "is_chat_fallback": false,
1490 "model_picker_enabled": true,
1491 "name": "GPT-4o",
1492 "object": "model",
1493 "preview": false,
1494 "vendor": "Azure OpenAI",
1495 "version": "gpt-4o"
1496 },
1497 {
1498 "billing": { "is_premium": true, "multiplier": 1 },
1499 "capabilities": {
1500 "family": "claude-sonnet-4",
1501 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1502 "object": "model_capabilities",
1503 "supports": { "streaming": true, "tool_calls": true },
1504 "type": "chat"
1505 },
1506 "id": "claude-sonnet-4",
1507 "is_chat_default": false,
1508 "is_chat_fallback": false,
1509 "model_picker_enabled": true,
1510 "name": "Claude Sonnet 4",
1511 "object": "model",
1512 "preview": false,
1513 "vendor": "Anthropic",
1514 "version": "claude-sonnet-4"
1515 },
1516 {
1517 "billing": { "is_premium": true, "multiplier": 1 },
1518 "capabilities": {
1519 "family": "gemini-2.0-flash",
1520 "limits": { "max_context_window_tokens": 1000000, "max_output_tokens": 8192, "max_prompt_tokens": 900000 },
1521 "object": "model_capabilities",
1522 "supports": { "streaming": true, "tool_calls": true },
1523 "type": "chat"
1524 },
1525 "id": "gemini-2.0-flash",
1526 "is_chat_default": false,
1527 "is_chat_fallback": false,
1528 "model_picker_enabled": true,
1529 "name": "Gemini 2.0 Flash",
1530 "object": "model",
1531 "preview": false,
1532 "vendor": "Google",
1533 "version": "gemini-2.0-flash"
1534 }
1535 ],
1536 "object": "list"
1537 }"#;
1538
1539 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1540
1541 // All three models from different vendors should be preserved
1542 assert_eq!(schema.data.len(), 3);
1543 assert_eq!(schema.data[0].id, "gpt-4o");
1544 assert_eq!(schema.data[1].id, "claude-sonnet-4");
1545 assert_eq!(schema.data[2].id, "gemini-2.0-flash");
1546 }
1547
1548 #[test]
1549 fn test_model_with_messages_endpoint_deserializes() {
1550 // Anthropic Claude models use /v1/messages endpoint.
1551 // This test verifies such models deserialize correctly (issue #47540 root cause).
1552 let json = r#"{
1553 "data": [
1554 {
1555 "billing": { "is_premium": true, "multiplier": 1 },
1556 "capabilities": {
1557 "family": "claude-sonnet-4",
1558 "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 },
1559 "object": "model_capabilities",
1560 "supports": { "streaming": true, "tool_calls": true },
1561 "type": "chat"
1562 },
1563 "id": "claude-sonnet-4",
1564 "is_chat_default": false,
1565 "is_chat_fallback": false,
1566 "model_picker_enabled": true,
1567 "name": "Claude Sonnet 4",
1568 "object": "model",
1569 "preview": false,
1570 "vendor": "Anthropic",
1571 "version": "claude-sonnet-4",
1572 "supported_endpoints": ["/v1/messages"]
1573 }
1574 ],
1575 "object": "list"
1576 }"#;
1577
1578 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1579
1580 assert_eq!(schema.data.len(), 1);
1581 assert_eq!(schema.data[0].id, "claude-sonnet-4");
1582 assert_eq!(
1583 schema.data[0].supported_endpoints,
1584 vec![ModelSupportedEndpoint::Messages]
1585 );
1586 }
1587
1588 #[test]
1589 fn test_model_with_unknown_endpoint_deserializes() {
1590 // Future-proofing: unknown endpoints should deserialize to Unknown variant
1591 // instead of causing the entire model to fail deserialization.
1592 let json = r#"{
1593 "data": [
1594 {
1595 "billing": { "is_premium": false, "multiplier": 1 },
1596 "capabilities": {
1597 "family": "future-model",
1598 "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 8192, "max_prompt_tokens": 120000 },
1599 "object": "model_capabilities",
1600 "supports": { "streaming": true, "tool_calls": true },
1601 "type": "chat"
1602 },
1603 "id": "future-model-v2",
1604 "is_chat_default": false,
1605 "is_chat_fallback": false,
1606 "model_picker_enabled": true,
1607 "name": "Future Model v2",
1608 "object": "model",
1609 "preview": false,
1610 "vendor": "OpenAI",
1611 "version": "v2.0",
1612 "supported_endpoints": ["/v2/completions", "/chat/completions"]
1613 }
1614 ],
1615 "object": "list"
1616 }"#;
1617
1618 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1619
1620 assert_eq!(schema.data.len(), 1);
1621 assert_eq!(schema.data[0].id, "future-model-v2");
1622 assert_eq!(
1623 schema.data[0].supported_endpoints,
1624 vec![
1625 ModelSupportedEndpoint::Unknown,
1626 ModelSupportedEndpoint::ChatCompletions
1627 ]
1628 );
1629 }
1630
1631 #[test]
1632 fn test_model_with_multiple_endpoints() {
1633 // Test model with multiple supported endpoints (common for newer models).
1634 let json = r#"{
1635 "data": [
1636 {
1637 "billing": { "is_premium": true, "multiplier": 1 },
1638 "capabilities": {
1639 "family": "gpt-4o",
1640 "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 },
1641 "object": "model_capabilities",
1642 "supports": { "streaming": true, "tool_calls": true },
1643 "type": "chat"
1644 },
1645 "id": "gpt-4o",
1646 "is_chat_default": true,
1647 "is_chat_fallback": false,
1648 "model_picker_enabled": true,
1649 "name": "GPT-4o",
1650 "object": "model",
1651 "preview": false,
1652 "vendor": "OpenAI",
1653 "version": "gpt-4o",
1654 "supported_endpoints": ["/chat/completions", "/responses"]
1655 }
1656 ],
1657 "object": "list"
1658 }"#;
1659
1660 let schema: ModelSchema = serde_json::from_str(json).unwrap();
1661
1662 assert_eq!(schema.data.len(), 1);
1663 assert_eq!(schema.data[0].id, "gpt-4o");
1664 assert_eq!(
1665 schema.data[0].supported_endpoints,
1666 vec![
1667 ModelSupportedEndpoint::ChatCompletions,
1668 ModelSupportedEndpoint::Responses
1669 ]
1670 );
1671 }
1672
1673 #[test]
1674 fn test_supports_response_method() {
1675 // Test the supports_response() method which determines endpoint routing.
1676 let model_with_responses_only = Model {
1677 billing: ModelBilling {
1678 is_premium: false,
1679 multiplier: 1.0,
1680 restricted_to: None,
1681 },
1682 capabilities: ModelCapabilities {
1683 family: "test".to_string(),
1684 limits: ModelLimits::default(),
1685 supports: ModelSupportedFeatures {
1686 streaming: true,
1687 tool_calls: true,
1688 parallel_tool_calls: false,
1689 vision: false,
1690 thinking: false,
1691 adaptive_thinking: false,
1692 max_thinking_budget: None,
1693 min_thinking_budget: None,
1694 reasoning_effort: vec![],
1695 },
1696 model_type: "chat".to_string(),
1697 tokenizer: None,
1698 },
1699 id: "test-model".to_string(),
1700 name: "Test Model".to_string(),
1701 policy: None,
1702 vendor: ModelVendor::OpenAI,
1703 is_chat_default: false,
1704 is_chat_fallback: false,
1705 model_picker_enabled: true,
1706 supported_endpoints: vec![ModelSupportedEndpoint::Responses],
1707 };
1708
1709 let model_with_chat_completions = Model {
1710 supported_endpoints: vec![ModelSupportedEndpoint::ChatCompletions],
1711 ..model_with_responses_only.clone()
1712 };
1713
1714 let model_with_both = Model {
1715 supported_endpoints: vec![
1716 ModelSupportedEndpoint::ChatCompletions,
1717 ModelSupportedEndpoint::Responses,
1718 ],
1719 ..model_with_responses_only.clone()
1720 };
1721
1722 let model_with_messages = Model {
1723 supported_endpoints: vec![ModelSupportedEndpoint::Messages],
1724 ..model_with_responses_only.clone()
1725 };
1726
1727 // Only /responses endpoint -> supports_response = true
1728 assert!(model_with_responses_only.supports_response());
1729
1730 // Only /chat/completions endpoint -> supports_response = false
1731 assert!(!model_with_chat_completions.supports_response());
1732
1733 // Both endpoints (has /chat/completions) -> supports_response = false
1734 assert!(!model_with_both.supports_response());
1735
1736 // Only /v1/messages endpoint -> supports_response = false (doesn't have /responses)
1737 assert!(!model_with_messages.supports_response());
1738 }
1739}