Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:03:04.105Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

copilot_chat.rs

1921 lines · 65.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.