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