Skip to main content

agent_engine/runtime/openai/catalog/
github_copilot.rs

1//! GitHub Copilot model catalog (C2 catalog + descriptor-driven routing).
2//!
3//! Live discovery hits the community-observed authenticated endpoint
4//! `GET https://api.githubcopilot.com/models` (experimental — not a documented
5//! stable third-party public product API). Prefer broker-proxied, account-
6//! specific discovery. Curated static fallback IDs are restricted to wire IDs
7//! established by fixtures/live discovery — never guessed display names.
8//!
9//! Runtime endpoint selection is *evidence-driven*: each fixture row exposes
10//! `supported_endpoints`, and the picker/router consult that list rather than
11//! guessing by model family. Currently reviewed broker paths are `/responses`
12//! and `/chat/completions`; `/v1/messages` is advertised by Anthropic-vendor
13//! rows but is not yet routed by this broker, so we fall back to
14//! `/chat/completions` where it is also advertised.
15//!
16//! See `docs/specs/github-copilot-model-catalog-spec.md`.
17
18use super::{
19    CatalogModel, CatalogProviderKind, CatalogSource, Modality, PricingSummary, ReasoningSupport,
20};
21use serde::Deserialize;
22
23/// Canonical provider key (matches OAuth storage / broker id).
24pub const PROVIDER_KEY: &str = "github-copilot";
25/// User-facing provider name.
26pub const PROVIDER_NAME: &str = "GitHub Copilot";
27
28/// Pinned experimental models host for personal Copilot catalog discovery.
29/// Community-observed; not a GitHub-documented stable third-party API.
30pub const MODELS_BASE_URL: &str = "https://api.githubcopilot.com";
31/// Relative path allowlisted on the broker for this slice (catalog only).
32pub const MODELS_PATH: &str = "/models";
33/// Full pinned models URL (host + path, no query).
34pub const MODELS_URL: &str = "https://api.githubcopilot.com/models";
35
36/// Live-verified API version header for `api.githubcopilot.com` catalog GETs.
37pub const COPILOT_API_VERSION: &str = "2025-10-01";
38
39/// Maximum response body accepted from the models endpoint (256 KiB).
40pub const MAX_MODELS_BODY_BYTES: usize = 256 * 1024;
41
42// ── Endpoint & policy shape ──────────────────────────────────────────────────
43
44/// Wire endpoint literals advertised on Copilot catalog rows.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum CopilotEndpoint {
47    /// OpenAI Responses (`/responses`).
48    Responses,
49    /// OpenAI Chat Completions (`/chat/completions`).
50    ChatCompletions,
51    /// Anthropic Messages (`/v1/messages`) — not currently broker-reviewed.
52    V1Messages,
53    /// WebSocket variant of `/responses` — out of scope for this broker.
54    WsResponses,
55    /// Anything else the upstream may add in the future.
56    Other(String),
57}
58
59impl CopilotEndpoint {
60    pub fn parse(s: &str) -> Self {
61        match s {
62            "/responses" => Self::Responses,
63            "/chat/completions" => Self::ChatCompletions,
64            "/v1/messages" => Self::V1Messages,
65            "ws:/responses" => Self::WsResponses,
66            other => Self::Other(other.to_string()),
67        }
68    }
69
70    /// Reviewed & broker-supported for outbound inference.
71    pub fn is_reviewed(&self) -> bool {
72        matches!(self, Self::Responses | Self::ChatCompletions)
73    }
74}
75
76/// Copilot per-model policy `state` — reflects account entitlement.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum CopilotPolicyState {
79    /// Row is opted-in for this account.
80    Enabled,
81    /// Row is admin-blocked / opt-in required — never selectable.
82    Disabled,
83    /// No `policy` object on the row (e.g. defaults). Not treated as disabled.
84    Unspecified,
85    /// Forward-compat: unknown state literal.
86    Other(String),
87}
88
89impl CopilotPolicyState {
90    fn from_opt(s: Option<&str>) -> Self {
91        match s {
92            None => Self::Unspecified,
93            Some("enabled") => Self::Enabled,
94            Some("disabled") => Self::Disabled,
95            Some(other) => Self::Other(other.to_string()),
96        }
97    }
98}
99
100// ── Typed descriptor for curated fallback ────────────────────────────────────
101
102/// Statically-selected wire endpoint for a curated fallback descriptor.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum CopilotWire {
105    Responses,
106    ChatCompletions,
107}
108
109impl CopilotWire {
110    pub fn to_wire_protocol(self) -> crate::runtime::openai::WireProtocol {
111        match self {
112            Self::Responses => crate::runtime::openai::WireProtocol::OpenAiResponses,
113            Self::ChatCompletions => crate::runtime::openai::WireProtocol::OpenAiChatCompletions,
114        }
115    }
116}
117
118/// Typed static descriptor for curated fallback models.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct CopilotModelDescriptor {
121    pub id: &'static str,
122    pub label: &'static str,
123    /// Upstream `vendor` string on the fixture row (informational).
124    pub vendor: &'static str,
125    /// Broker wire selected from the row's `supported_endpoints`.
126    pub selected_wire: CopilotWire,
127}
128
129/// Curated fallback — every entry is proven by [`selectable_copilot_entries`]
130/// on the reviewed fixture: `type == chat`, `model_picker_enabled == true`,
131/// and at least one reviewed endpoint. Policy/picker flags are opt-in metadata. Ordered
132/// for UI presentation.
133pub const COPILOT_FALLBACK_MODELS: &[CopilotModelDescriptor] = &[
134    CopilotModelDescriptor {
135        id: "gpt-5.3-codex",
136        label: "GPT-5.3-Codex",
137        vendor: "OpenAI",
138        selected_wire: CopilotWire::Responses,
139    },
140    CopilotModelDescriptor {
141        id: "gpt-5.4",
142        label: "GPT-5.4",
143        vendor: "OpenAI",
144        selected_wire: CopilotWire::Responses,
145    },
146    CopilotModelDescriptor {
147        id: "gpt-5.4-mini",
148        label: "GPT-5.4 mini",
149        vendor: "OpenAI",
150        selected_wire: CopilotWire::Responses,
151    },
152    CopilotModelDescriptor {
153        id: "gpt-5.6-luna",
154        label: "GPT-5.6 Luna",
155        vendor: "OpenAI",
156        selected_wire: CopilotWire::Responses,
157    },
158    CopilotModelDescriptor {
159        id: "gpt-5.6-terra",
160        label: "GPT-5.6 Terra",
161        vendor: "OpenAI",
162        selected_wire: CopilotWire::Responses,
163    },
164    CopilotModelDescriptor {
165        id: "gpt-5-mini",
166        label: "GPT-5 mini",
167        vendor: "Azure OpenAI",
168        selected_wire: CopilotWire::Responses,
169    },
170    CopilotModelDescriptor {
171        id: "gpt-5.5",
172        label: "GPT-5.5",
173        vendor: "OpenAI",
174        selected_wire: CopilotWire::Responses,
175    },
176    CopilotModelDescriptor {
177        id: "claude-fable-5",
178        label: "Claude Fable 5",
179        vendor: "Anthropic",
180        selected_wire: CopilotWire::ChatCompletions,
181    },
182    CopilotModelDescriptor {
183        id: "claude-opus-4.7",
184        label: "Claude Opus 4.7",
185        vendor: "Anthropic",
186        selected_wire: CopilotWire::ChatCompletions,
187    },
188    CopilotModelDescriptor {
189        id: "claude-opus-4.8",
190        label: "Claude Opus 4.8",
191        vendor: "Anthropic",
192        selected_wire: CopilotWire::ChatCompletions,
193    },
194    CopilotModelDescriptor {
195        id: "claude-opus-4.8-fast",
196        label: "Claude Opus 4.8 (fast mode)",
197        vendor: "Anthropic",
198        selected_wire: CopilotWire::ChatCompletions,
199    },
200    CopilotModelDescriptor {
201        id: "claude-sonnet-4.6",
202        label: "Claude Sonnet 4.6",
203        vendor: "Anthropic",
204        selected_wire: CopilotWire::ChatCompletions,
205    },
206    CopilotModelDescriptor {
207        id: "claude-sonnet-5",
208        label: "Claude Sonnet 5",
209        vendor: "Anthropic",
210        selected_wire: CopilotWire::ChatCompletions,
211    },
212    CopilotModelDescriptor {
213        id: "claude-haiku-4.5",
214        label: "Claude Haiku 4.5",
215        vendor: "Anthropic",
216        selected_wire: CopilotWire::ChatCompletions,
217    },
218    CopilotModelDescriptor {
219        id: "gemini-3.1-pro-preview",
220        label: "Gemini 3.1 Pro",
221        vendor: "Google",
222        selected_wire: CopilotWire::ChatCompletions,
223    },
224    CopilotModelDescriptor {
225        id: "gemini-3.5-flash",
226        label: "Gemini 3.5 Flash",
227        vendor: "Google",
228        selected_wire: CopilotWire::ChatCompletions,
229    },
230];
231
232/// Headers attached to the experimental Copilot models GET.
233/// Session bearer is applied by the broker — never pass the GitHub user token.
234pub fn models_request_headers() -> &'static [(&'static str, &'static str)] {
235    &[
236        ("User-Agent", "SynapsCLI/0.6.0"),
237        ("Accept", "application/json"),
238        ("Editor-Version", "vscode/1.107.0"),
239        ("Editor-Plugin-Version", "copilot-chat/0.35.0"),
240        ("Copilot-Integration-Id", "vscode-chat"),
241        ("X-Github-Api-Version", COPILOT_API_VERSION),
242    ]
243}
244
245/// Validate the pinned models URL (fail closed on host/path drift).
246pub fn validate_models_endpoint(url: &str) -> Result<(), String> {
247    if url != MODELS_URL {
248        return Err("github-copilot models endpoint is not the pinned URL".into());
249    }
250    Ok(())
251}
252
253/// Look up a curated fallback descriptor by wire id.
254pub fn copilot_model(id: &str) -> Option<&'static CopilotModelDescriptor> {
255    COPILOT_FALLBACK_MODELS.iter().find(|m| m.id == id)
256}
257
258/// Select the wire protocol from endpoints established by the captured live
259/// catalog. This is descriptor-driven: only IDs whose reviewed endpoint has
260/// been established by the fixture route. Unknown account models fail closed.
261pub fn runtime_wire_protocol(id: &str) -> Option<crate::runtime::openai::WireProtocol> {
262    copilot_model(id).map(|d| d.selected_wire.to_wire_protocol())
263}
264
265/// Choose a broker wire protocol from a fixture row's `vendor` and
266/// `supported_endpoints`. Returns `None` when no reviewed endpoint is
267/// advertised (fail closed).
268///
269/// Preference:
270/// - OpenAI / Azure OpenAI: prefer `/responses`, else `/chat/completions`.
271/// - Anything else (Google, Anthropic, unknown): prefer `/chat/completions`,
272///   else `/responses`. Anthropic's `/v1/messages` is *not* selected because
273///   the broker does not yet route it.
274pub fn preferred_wire_protocol_from_endpoints(
275    vendor: Option<&str>,
276    endpoints: &[CopilotEndpoint],
277) -> Option<crate::runtime::openai::WireProtocol> {
278    use crate::runtime::openai::WireProtocol;
279    let has_responses = endpoints
280        .iter()
281        .any(|e| matches!(e, CopilotEndpoint::Responses));
282    let has_chat = endpoints
283        .iter()
284        .any(|e| matches!(e, CopilotEndpoint::ChatCompletions));
285    let prefer_responses = matches!(vendor, Some("OpenAI") | Some("Azure OpenAI"));
286    if prefer_responses && has_responses {
287        return Some(WireProtocol::OpenAiResponses);
288    }
289    if has_chat {
290        return Some(WireProtocol::OpenAiChatCompletions);
291    }
292    if has_responses {
293        return Some(WireProtocol::OpenAiResponses);
294    }
295    None
296}
297
298/// Static fallback catalog for offline / seed UI paths. Every entry here is
299/// selectable by construction (see [`COPILOT_FALLBACK_MODELS`]).
300pub fn copilot_static_catalog_models() -> Vec<CatalogModel> {
301    COPILOT_FALLBACK_MODELS
302        .iter()
303        .map(|descriptor| CatalogModel {
304            provider_key: PROVIDER_KEY.into(),
305            provider_name: PROVIDER_NAME.into(),
306            provider_kind: CatalogProviderKind::Generic {
307                key: PROVIDER_KEY.into(),
308            },
309            id: descriptor.id.into(),
310            label: Some(descriptor.label.into()),
311            context_tokens: None,
312            max_output_tokens: None,
313            input_modalities: vec![Modality::Text],
314            pricing: PricingSummary::default(),
315            reasoning: ReasoningSupport::Unknown,
316            source: CatalogSource::StaticFallback,
317        })
318        .collect()
319}
320
321// ── Wire parse (experimental) ────────────────────────────────────────────────
322
323#[derive(Debug, Deserialize)]
324struct CopilotModelsResponse {
325    data: Vec<CopilotModelItem>,
326}
327
328#[derive(Debug, Deserialize)]
329struct CopilotModelItem {
330    id: String,
331    #[serde(default)]
332    name: Option<String>,
333    #[serde(default)]
334    vendor: Option<String>,
335    #[serde(default)]
336    capabilities: Option<CopilotCapabilities>,
337    #[serde(default)]
338    preview: Option<bool>,
339    #[serde(default)]
340    model_picker_enabled: Option<bool>,
341    #[serde(default)]
342    is_chat_default: Option<bool>,
343    #[serde(default)]
344    is_chat_fallback: Option<bool>,
345    #[serde(default)]
346    policy: Option<CopilotPolicy>,
347    #[serde(default)]
348    supported_endpoints: Option<Vec<String>>,
349}
350
351#[derive(Debug, Deserialize)]
352struct CopilotPolicy {
353    #[serde(default)]
354    state: Option<String>,
355}
356
357#[derive(Debug, Deserialize)]
358struct CopilotCapabilities {
359    #[serde(default)]
360    #[serde(rename = "type")]
361    kind: Option<String>,
362    #[serde(default)]
363    limits: Option<CopilotLimits>,
364    #[serde(default)]
365    supports: Option<CopilotSupports>,
366}
367
368#[derive(Debug, Deserialize)]
369struct CopilotLimits {
370    #[serde(default)]
371    max_context_window_tokens: Option<u64>,
372    #[serde(default)]
373    max_output_tokens: Option<u64>,
374    #[serde(default)]
375    max_prompt_tokens: Option<u64>,
376}
377
378#[derive(Debug, Deserialize)]
379struct CopilotSupports {
380    #[serde(default)]
381    vision: Option<bool>,
382    #[serde(default)]
383    reasoning_effort: Option<serde_json::Value>,
384    #[serde(default)]
385    adaptive_thinking: Option<bool>,
386}
387
388/// Rich, evidence-preserving row from an experimental Copilot `/models` body.
389///
390/// Unlike [`CatalogModel`] this keeps every dimension the picker/router
391/// consults: policy state, picker flag, vendor, advertised endpoints, and
392/// capability hints. Use [`selectable_copilot_entries`] for the filtered
393/// picker view.
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct CopilotCatalogEntry {
396    pub id: String,
397    pub label: String,
398    pub vendor: Option<String>,
399    /// Capabilities `type` (`chat`, `embeddings`, `completion`, …).
400    pub kind: String,
401    pub model_picker_enabled: bool,
402    pub policy_state: CopilotPolicyState,
403    pub preview: bool,
404    pub is_chat_default: bool,
405    pub is_chat_fallback: bool,
406    pub supported_endpoints: Vec<CopilotEndpoint>,
407    pub context_tokens: Option<u64>,
408    pub max_output_tokens: Option<u64>,
409    pub vision: bool,
410    pub thinking: bool,
411}
412
413impl CopilotCatalogEntry {
414    /// Broker wire protocol chosen from vendor + `supported_endpoints`.
415    pub fn preferred_wire_protocol(&self) -> Option<crate::runtime::openai::WireProtocol> {
416        preferred_wire_protocol_from_endpoints(self.vendor.as_deref(), &self.supported_endpoints)
417    }
418
419    /// True when this row should surface in the picker AND is routable.
420    pub fn is_selectable_for_picker(&self) -> bool {
421        self.kind.eq_ignore_ascii_case("chat")
422            && (self.model_picker_enabled
423                || matches!(self.policy_state, CopilotPolicyState::Disabled))
424            && self.preferred_wire_protocol().is_some()
425    }
426}
427
428fn body_size_guard(body: &str) -> Result<(), String> {
429    if body.len() > MAX_MODELS_BODY_BYTES {
430        return Err(format!(
431            "github-copilot models body exceeded the {MAX_MODELS_BODY_BYTES}-byte cap"
432        ));
433    }
434    Ok(())
435}
436
437fn parse_response(body: &str) -> Result<CopilotModelsResponse, String> {
438    body_size_guard(body)?;
439    serde_json::from_str(body).map_err(|e| format!("github-copilot models parse failed: {e}"))
440}
441
442fn to_entry(item: CopilotModelItem) -> Option<CopilotCatalogEntry> {
443    let id = item.id.trim().to_string();
444    if id.is_empty() {
445        return None;
446    }
447    let caps = item.capabilities.as_ref();
448    let kind = caps
449        .and_then(|c| c.kind.as_deref())
450        .unwrap_or("chat")
451        .to_string();
452    let (context_tokens, max_output_tokens, vision, thinking) = caps
453        .map(|c| {
454            let (ctx, out) = c
455                .limits
456                .as_ref()
457                .map(|l| {
458                    (
459                        l.max_context_window_tokens.or(l.max_prompt_tokens),
460                        l.max_output_tokens,
461                    )
462                })
463                .unwrap_or((None, None));
464            let (vision, thinking) = c
465                .supports
466                .as_ref()
467                .map(|s| {
468                    let thinking = s.adaptive_thinking == Some(true)
469                        || s.reasoning_effort.as_ref().is_some_and(|v| !v.is_null());
470                    (s.vision.unwrap_or(false), thinking)
471                })
472                .unwrap_or((false, false));
473            (ctx, out, vision, thinking)
474        })
475        .unwrap_or((None, None, false, false));
476    let supported_endpoints = item
477        .supported_endpoints
478        .unwrap_or_default()
479        .into_iter()
480        .map(|s| CopilotEndpoint::parse(&s))
481        .collect();
482    let label = item
483        .name
484        .filter(|n| !n.trim().is_empty())
485        .unwrap_or_else(|| id.clone());
486    Some(CopilotCatalogEntry {
487        id,
488        label,
489        vendor: item.vendor.filter(|v| !v.trim().is_empty()),
490        kind,
491        model_picker_enabled: item.model_picker_enabled.unwrap_or(false),
492        policy_state: CopilotPolicyState::from_opt(item.policy.and_then(|p| p.state).as_deref()),
493        preview: item.preview.unwrap_or(false),
494        is_chat_default: item.is_chat_default.unwrap_or(false),
495        is_chat_fallback: item.is_chat_fallback.unwrap_or(false),
496        supported_endpoints,
497        context_tokens,
498        max_output_tokens,
499        vision,
500        thinking,
501    })
502}
503
504/// Parse an experimental Copilot `/models` body into rich descriptor entries.
505///
506/// Fails closed on malformed JSON or a missing `data` array. All rows are
507/// preserved (no capability-based filter here) so callers can distinguish
508/// disabled/utility rows from selectable ones without re-parsing.
509pub fn parse_copilot_catalog_entries(body: &str) -> Result<Vec<CopilotCatalogEntry>, String> {
510    let resp = parse_response(body)?;
511    Ok(resp.data.into_iter().filter_map(to_entry).collect())
512}
513
514/// Parse an experimental Copilot `/models` body into normalized catalog rows.
515///
516/// The returned view includes chat rows with at least one reviewed endpoint.
517/// A disabled policy identifies an account opt-in row and does not hide it; otherwise
518/// the upstream picker flag remains the utility-row visibility boundary. Callers wanting the raw evidence use
519/// [`parse_copilot_catalog_entries`].
520pub fn parse_copilot_catalog_models(body: &str) -> Result<Vec<CatalogModel>, String> {
521    let entries = parse_copilot_catalog_entries(body)?;
522    Ok(entries
523        .into_iter()
524        .filter(CopilotCatalogEntry::is_selectable_for_picker)
525        .filter_map(|e| catalog_model_from_entry(&e))
526        .collect())
527}
528
529/// Selectable-only view — helper for pickers and expanded-fallback checks.
530pub fn selectable_copilot_entries(body: &str) -> Result<Vec<CopilotCatalogEntry>, String> {
531    Ok(parse_copilot_catalog_entries(body)?
532        .into_iter()
533        .filter(CopilotCatalogEntry::is_selectable_for_picker)
534        .collect())
535}
536
537fn catalog_model_from_entry(e: &CopilotCatalogEntry) -> Option<CatalogModel> {
538    let mut m = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, &e.id)?;
539    m.provider_kind = CatalogProviderKind::Generic {
540        key: PROVIDER_KEY.into(),
541    };
542    m.label = Some(e.label.clone());
543    m.context_tokens = e.context_tokens;
544    m.max_output_tokens = e.max_output_tokens;
545    let mut modalities = vec![Modality::Text];
546    if e.vision {
547        modalities.push(Modality::Image);
548    }
549    m.input_modalities = modalities;
550    m.reasoning = if e.thinking {
551        ReasoningSupport::GenericOpenAi
552    } else {
553        ReasoningSupport::Unknown
554    };
555    m.source = CatalogSource::Live;
556    Some(m)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    const LIVE_FIXTURE: &str = include_str!("fixtures/github_copilot_models.json");
564
565    #[test]
566    fn fallback_wire_ids_are_fixture_selectable_set() {
567        let entries = parse_copilot_catalog_entries(LIVE_FIXTURE).expect("fixture parse");
568        let selectable: std::collections::HashSet<_> = entries
569            .iter()
570            .filter(|e| e.is_selectable_for_picker())
571            .map(|e| e.id.as_str())
572            .collect();
573        let fallback: std::collections::HashSet<_> =
574            COPILOT_FALLBACK_MODELS.iter().map(|d| d.id).collect();
575        assert_eq!(
576            fallback, selectable,
577            "curated fallback must match the fixture's selectable set exactly"
578        );
579        // Never seed retired / unobserved ids.
580        assert!(copilot_model("gpt-5.6-sol").is_none());
581        assert!(copilot_model("auto").is_none());
582        assert!(copilot_model("gpt-4.1").is_none());
583        assert!(copilot_model("claude-sonnet-4").is_none());
584        assert!(copilot_model("gemini-3-pro").is_none());
585        // Endpointless rows remain excluded.
586        assert!(copilot_model("gemini-3-flash-preview").is_none());
587    }
588
589    #[test]
590    fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
591        let models = copilot_static_catalog_models();
592        assert_eq!(models.len(), COPILOT_FALLBACK_MODELS.len());
593        assert!(models
594            .iter()
595            .all(|m| m.source == CatalogSource::StaticFallback));
596        assert!(models
597            .iter()
598            .all(|m| m.runtime_id().starts_with("github-copilot/")));
599        assert_eq!(models[0].id, "gpt-5.3-codex");
600        assert_eq!(models[0].label.as_deref(), Some("GPT-5.3-Codex"));
601    }
602
603    #[test]
604    fn models_endpoint_is_pinned() {
605        validate_models_endpoint(MODELS_URL).unwrap();
606        assert!(
607            validate_models_endpoint("https://api.individual.githubcopilot.com/models").is_err()
608        );
609        assert!(validate_models_endpoint("https://api.githubcopilot.com/models/").is_err());
610        assert!(validate_models_endpoint("https://evil.example/models").is_err());
611        assert!(validate_models_endpoint("http://api.githubcopilot.com/models").is_err());
612    }
613
614    #[test]
615    fn models_headers_include_integration_and_api_version() {
616        let map: std::collections::HashMap<_, _> =
617            models_request_headers().iter().copied().collect();
618        assert_eq!(map.get("User-Agent"), Some(&"SynapsCLI/0.6.0"));
619        assert_eq!(map.get("Copilot-Integration-Id"), Some(&"vscode-chat"));
620        assert_eq!(map.get("X-Github-Api-Version"), Some(&COPILOT_API_VERSION));
621        assert_eq!(map.get("Editor-Version"), Some(&"vscode/1.107.0"));
622    }
623
624    #[test]
625    fn parse_rejects_malformed_and_missing_data() {
626        assert!(parse_copilot_catalog_models("{not json}").is_err());
627        assert!(parse_copilot_catalog_models(r#"{"models":[]}"#).is_err());
628        assert!(parse_copilot_catalog_models("[]").is_err());
629    }
630
631    #[test]
632    fn parse_rejects_oversized_body() {
633        let huge = format!(
634            "{{\"data\":[{{\"id\":\"x\",\"capabilities\":{{\"type\":\"chat\"}}}}{}]}}",
635            " ".repeat(MAX_MODELS_BODY_BYTES)
636        );
637        let err = parse_copilot_catalog_models(&huge).unwrap_err();
638        assert!(err.contains("exceeded"), "{err}");
639    }
640
641    #[test]
642    fn parse_filters_non_chat_and_endpointless_but_keeps_opt_in_rows() {
643        let body = r#"{
644          "object":"list",
645          "data":[
646            {"id":"","capabilities":{"type":"chat"}},
647            {"id":"embed-1","name":"Embed","capabilities":{"type":"embeddings"},
648             "model_picker_enabled":true,"policy":{"state":"enabled"},
649             "supported_endpoints":["/chat/completions"]},
650            {"id":"complete-1","name":"Complete","capabilities":{"type":"completion"},
651             "model_picker_enabled":true,"policy":{"state":"enabled"},
652             "supported_endpoints":["/chat/completions"]},
653            {"id":"disabled-chat","name":"Disabled","capabilities":{"type":"chat"},
654             "model_picker_enabled":true,"policy":{"state":"disabled"},
655             "supported_endpoints":["/chat/completions"]},
656            {"id":"unpicked-chat","name":"Hidden","capabilities":{"type":"chat"},
657             "model_picker_enabled":false,"policy":{"state":"enabled"},
658             "supported_endpoints":["/chat/completions"]},
659            {"id":"endpointless","name":"Endpointless","capabilities":{"type":"chat"},
660             "model_picker_enabled":true,"policy":{"state":"enabled"}},
661            {"id":"only-v1-messages","name":"OnlyMessages","vendor":"Anthropic",
662             "capabilities":{"type":"chat"},"model_picker_enabled":true,
663             "policy":{"state":"enabled"},"supported_endpoints":["/v1/messages"]},
664            {"id":"only-ws","name":"OnlyWs","vendor":"OpenAI",
665             "capabilities":{"type":"chat"},"model_picker_enabled":true,
666             "policy":{"state":"enabled"},"supported_endpoints":["ws:/responses"]},
667            {"id":"gpt-x","name":"GPT-X","vendor":"OpenAI","capabilities":{
668               "type":"chat",
669               "limits":{"max_context_window_tokens":128000,"max_output_tokens":16384},
670               "supports":{"vision":true,"reasoning_effort":["low","high"]}
671             },"model_picker_enabled":true,"policy":{"state":"enabled"},
672             "supported_endpoints":["/responses","/chat/completions"]}
673          ]
674        }"#;
675        let models = parse_copilot_catalog_models(body).expect("parse");
676        let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect();
677        assert_eq!(ids, vec!["disabled-chat", "gpt-x"]);
678        let gpt = &models[1];
679        assert_eq!(gpt.context_tokens, Some(128000));
680        assert_eq!(gpt.max_output_tokens, Some(16384));
681        assert!(gpt.input_modalities.contains(&Modality::Image));
682        assert_eq!(gpt.reasoning, ReasoningSupport::GenericOpenAi);
683        assert_eq!(gpt.source, CatalogSource::Live);
684        assert_eq!(gpt.runtime_id(), "github-copilot/gpt-x");
685    }
686
687    #[test]
688    fn parse_live_fixture_keeps_only_selectable_rows() {
689        let models = parse_copilot_catalog_models(LIVE_FIXTURE).expect("fixture parse");
690        let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
691        // All chat rows with reviewed endpoints MUST be present.
692        for expected in [
693            "gpt-5.3-codex",
694            "gpt-5.4",
695            "gpt-5.4-mini",
696            "gpt-5.6-luna",
697            "gpt-5.6-terra",
698            "gpt-5-mini",
699            "claude-sonnet-4.6",
700            "claude-sonnet-5",
701            "claude-haiku-4.5",
702            "gemini-3.1-pro-preview",
703            "gemini-3.5-flash",
704            "claude-fable-5",
705            "claude-opus-4.7",
706            "claude-opus-4.8",
707            "claude-opus-4.8-fast",
708            "gpt-5.5",
709        ] {
710            assert!(ids.contains(expected), "missing {expected}");
711        }
712        // Endpointless and non-chat rows MUST NOT be present.
713        for banned in [
714            "gemini-3-flash-preview",
715            "text-embedding-3-small",
716            "gpt-41-copilot",
717            "trajectory-compaction",
718        ] {
719            assert!(!ids.contains(banned), "must filter {banned}");
720        }
721        assert!(models.iter().all(|m| m.provider_key == PROVIDER_KEY));
722        assert!(models
723            .iter()
724            .all(|m| m.runtime_id().starts_with("github-copilot/")));
725    }
726
727    #[test]
728    fn every_fallback_id_selectable_in_live_fixture() {
729        let selectable = selectable_copilot_entries(LIVE_FIXTURE).expect("fixture");
730        let ids: std::collections::HashSet<_> = selectable.iter().map(|e| e.id.as_str()).collect();
731        for d in COPILOT_FALLBACK_MODELS {
732            assert!(
733                ids.contains(d.id),
734                "fallback id {} is not selectable in live fixture",
735                d.id
736            );
737            // And the descriptor's static endpoint pick must match the live
738            // evidence-driven pick (no drift between fallback and fixture).
739            let entry = selectable.iter().find(|e| e.id == d.id).unwrap();
740            assert_eq!(
741                Some(d.selected_wire.to_wire_protocol()),
742                entry.preferred_wire_protocol(),
743                "descriptor `{}` disagrees with fixture endpoint pick",
744                d.id
745            );
746        }
747    }
748
749    #[test]
750    fn endpoint_preference_openai_prefers_responses_others_prefer_chat() {
751        use crate::runtime::openai::WireProtocol;
752        // OpenAI + both → responses.
753        assert_eq!(
754            preferred_wire_protocol_from_endpoints(
755                Some("OpenAI"),
756                &[CopilotEndpoint::Responses, CopilotEndpoint::ChatCompletions]
757            ),
758            Some(WireProtocol::OpenAiResponses)
759        );
760        // OpenAI + only chat → chat.
761        assert_eq!(
762            preferred_wire_protocol_from_endpoints(
763                Some("OpenAI"),
764                &[CopilotEndpoint::ChatCompletions]
765            ),
766            Some(WireProtocol::OpenAiChatCompletions)
767        );
768        // Azure OpenAI treated as OpenAI.
769        assert_eq!(
770            preferred_wire_protocol_from_endpoints(
771                Some("Azure OpenAI"),
772                &[CopilotEndpoint::ChatCompletions, CopilotEndpoint::Responses]
773            ),
774            Some(WireProtocol::OpenAiResponses)
775        );
776        // Anthropic: /v1/messages advertised is IGNORED; chat wins.
777        assert_eq!(
778            preferred_wire_protocol_from_endpoints(
779                Some("Anthropic"),
780                &[
781                    CopilotEndpoint::V1Messages,
782                    CopilotEndpoint::ChatCompletions
783                ]
784            ),
785            Some(WireProtocol::OpenAiChatCompletions)
786        );
787        // Anthropic w/ only /v1/messages → fail closed.
788        assert_eq!(
789            preferred_wire_protocol_from_endpoints(
790                Some("Anthropic"),
791                &[CopilotEndpoint::V1Messages]
792            ),
793            None
794        );
795        // Google: chat only.
796        assert_eq!(
797            preferred_wire_protocol_from_endpoints(
798                Some("Google"),
799                &[CopilotEndpoint::ChatCompletions]
800            ),
801            Some(WireProtocol::OpenAiChatCompletions)
802        );
803        // ws:/responses alone → fail closed regardless of vendor.
804        assert_eq!(
805            preferred_wire_protocol_from_endpoints(Some("OpenAI"), &[CopilotEndpoint::WsResponses]),
806            None
807        );
808        // Empty list → None.
809        assert_eq!(
810            preferred_wire_protocol_from_endpoints(Some("OpenAI"), &[]),
811            None
812        );
813    }
814}