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