copilot_chat.rs

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