From c61977cb703836425a8d505b110e29d50218e819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 21 Sep 2026 15:12:54 -0400 Subject: [PATCH] feat(models): fetch the openai-codex catalog live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ChatGPT Codex catalog was compiled in: four models read once from the Codex CLI cache. The backend moved on — `gpt-6-astra` shipped, and `gpt-5.3-codex-spark` left — so the picker showed a table that no longer matched the account. `GET https://chatgpt.com/backend-api/codex/models?client_version=` serves that table. Verified against a live ChatGPT account: the OAuth Bearer alone returns 200, the query is required (400 without it), and the backend gates the catalog on it — `0.104.0` answers with `codex-auto-review` alone, `0.150.0` omits `gpt-6-astra`, `0.155.1` serves the full set. `CODEX_CLIENT_VERSION` pins the current upstream release (openai/codex rust-v0.155.1) and the codex `User-Agent` now derives from it, so the two can never drift. `ListingDialect::OpenaiCodex` + `parse_openai_codex_listing` keep the `visibility == "list" && supported_in_api` models and carry the wire context window and per-model reasoning levels; `hardcoded_catalog` and its short-circuit in the fetch path are gone. The catalog advertises an `ultra` tier kigi has no level for — `think_efforts_to_options` drops it with a warning, as it does any unknown token. --- AGENTS.md | 30 +- crates/codegen/kigi-models/src/lib.rs | 270 +++++++----------- .../codegen/kigi-sampling-types/src/types.rs | 26 +- .../kigi-shell/src/agent/models_fetch.rs | 127 ++++---- 4 files changed, 213 insertions(+), 240 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b74f2a8..5643f06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -383,13 +383,18 @@ client-side, no backend surface. API-key `openai` Responses requests carry NONE of this (byte-identical, pinned by a control wire test). `reasoning.effort` carries the thinking level. NO websocket, NO base_instructions. - CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` → - `openai_codex_wire_models`, mapped through the SAME - `platform_wire_model_to_entry` output): exactly the 4 `visibility=list` && - `supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx - 272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI / - `~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and - `gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED. + CATALOG is LIVE (`ListingDialect::OpenaiCodex` → + `parse_openai_codex_listing`): `GET {base}/models?client_version= + {CODEX_CLIENT_VERSION}` with the Bearer alone (no identity headers — + verified), keeping the `visibility=="list" && supported_in_api` models + and carrying their context window + per-model efforts. The query is + REQUIRED (400 without it) and the backend GATES the catalog on it: + `0.104.0` answers with `codex-auto-review` alone, `0.150.0` omits + `gpt-6-astra`, `0.155.1` serves the full set — so + `CODEX_CLIENT_VERSION` (kigi-sampling-types, also the `User-Agent` + version) must track a real upstream codex release, and a model that + never appears is a version-gate symptom first. NO codex-CLI / `~/.codex` + dependency. - `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE). Provider: `github-copilot` (`scope_key oauth/github-copilot`, base `api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an @@ -532,9 +537,14 @@ client-side, no backend surface. `~/.kigi/models_dev_cache.json`). Wire values always win; enrichment never invents model availability. Canonical reasoning efforts: none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07; - `ultra` was dropped the same month, no backend accepts it; Kimi wire - spells its top tier `max`, kimi_compat renames). config.toml accepts any - letter case: `ReasoningEffort` deserializes through `FromStr`. + Kimi wire spells its top tier `max`, kimi_compat renames). config.toml + accepts any letter case: `ReasoningEffort` deserializes through `FromStr`. + The codex catalog advertises an `ultra` tier above `max` for + `gpt-6-astra`/`gpt-5.6-sol`/`gpt-5.6-terra`; kigi has no such level and + `think_efforts_to_options` drops it with a warning. The models.dev provider + for the Kimi coding plan is `kimi-code-plan-cn` (api.kimi.com — kigi's + endpoint); it was renamed from `kimi-for-coding` in 2026-09 and + `kimi-code-plan-global` is the api.kimi.ai twin. ## Milestones (PRD §8.3) diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 5732151..f2abbbb 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -61,6 +61,11 @@ pub enum ListingDialect { /// headers, Anthropic's response shape (parsed by /// [`parse_anthropic_listing`]). Anthropic, + /// `GET {base}/models?client_version=`, + /// `Authorization: Bearer`, `{models:[{slug,...}]}` (parsed by + /// [`parse_openai_codex_listing`]). The query is REQUIRED (400 without it) + /// and gates which models the backend returns. + OpenaiCodex, } /// ChatCompletions body-adaptation dialect (leaf-safe mirror of the @@ -400,7 +405,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { vendor: "Kimi", console_host: None, login_label: None, - models_dev_id: Some("kimi-for-coding"), + models_dev_id: Some("kimi-code-plan-cn"), wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, listing: ListingDialect::OpenAi, @@ -987,7 +992,7 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec { vendor: "Kimi", console_host: Some("www.kimi.com"), login_label: Some("Kimi For Coding (API key)"), - models_dev_id: Some("kimi-for-coding"), + models_dev_id: Some("kimi-code-plan-cn"), wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, listing: ListingDialect::OpenAi, @@ -1267,17 +1272,12 @@ const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec { vendor: "OpenAI", console_host: Some("chatgpt.com"), login_label: Some("ChatGPT Plus/Pro (Codex)"), - // HARDCODED catalog (see `hardcoded_catalog`): NOT enriched from models.dev - // and NOT live-fetched — OpenAI exposes no stable public models endpoint for - // this backend. + // The Codex backend serves its own catalog with context + per-model + // reasoning levels, so models.dev enrichment is neither used nor needed. models_dev_id: None, - // The catalog is compiled-in and already carries context/thinking metadata, - // so enrichment (and its network refresh) is skipped entirely. wire_serves_metadata: true, wire_api: PlatformWireApi::Responses, - // Unused: the catalog does NOT come from a live `/models` listing (the - // fetch path short-circuits to `hardcoded_catalog`). - listing: ListingDialect::OpenAi, + listing: ListingDialect::OpenaiCodex, chat_compat: PlatformChatCompat::Passthrough, key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, @@ -1566,79 +1566,6 @@ impl PlatformId { pub fn sends_codex_responses_headers(self) -> bool { matches!(self, Self::OpenaiCodex) } - - /// The compiled-in catalog for a platform that serves NO live `/models` - /// listing (openai-codex), or `None` when the catalog comes from the wire. - /// - /// openai-codex's 4 models are HARDCODED (read from the official Codex CLI's - /// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true` - /// set) because OpenAI exposes no stable public models endpoint for the - /// ChatGPT Codex backend. Each entry carries context window + per-model - /// selectable reasoning efforts (incl. the codex-only `xhigh`/`max` - /// tiers), so the fetch path maps them through the SAME - /// `platform_wire_model_to_entry` output as a live listing — no new type. - pub fn hardcoded_catalog(self) -> Option> { - match self { - Self::OpenaiCodex => Some(openai_codex_wire_models()), - _ => None, - } - } -} - -/// One hardcoded openai-codex model as a [`WireModel`] (context 272000, thinking -/// + image input, selectable efforts). `efforts` are the exact per-model -/// supported tiers; `default` is the model's default effort. -fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &str) -> WireModel { - WireModel { - id: slug.to_string(), - context_length: 272_000, - supports_reasoning: true, - supports_image_in: true, - supports_video_in: false, - display_name: Some(display_name.to_string()), - max_output_tokens: 0, - supports_thinking_type: None, - think_efforts: Some(WireThinkEfforts { - support: true, - valid_efforts: efforts.iter().map(|s| (*s).to_string()).collect(), - default_effort: Some(default.to_string()), - }), - } -} - -/// The HARDCODED openai-codex catalog: exactly the 4 `visibility=="list"` AND -/// `supported_in_api==true` models from the Codex CLI model cache. The -/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses), -/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models -/// are deliberately EXCLUDED — they would list-but-not-work or are not -/// user-facing (fail-fast: never advertise a model the backend rejects). -fn openai_codex_wire_models() -> Vec { - vec![ - codex_wire_model( - "gpt-5.6-sol", - "GPT-5.6-Sol", - &["low", "medium", "high", "xhigh", "max"], - "low", - ), - codex_wire_model( - "gpt-5.6-terra", - "GPT-5.6-Terra", - &["low", "medium", "high", "xhigh", "max"], - "medium", - ), - codex_wire_model( - "gpt-5.6-luna", - "GPT-5.6-Luna", - &["low", "medium", "high", "xhigh", "max"], - "medium", - ), - codex_wire_model( - "gpt-5.5", - "GPT-5.5", - &["low", "medium", "high", "xhigh"], - "medium", - ), - ] } /// Split a managed catalog key `{platform_id}/{model_id}` back into its @@ -2084,6 +2011,72 @@ pub fn parse_github_copilot_listing(json: &str) -> Result, serde_ Ok(kept) } +/// One entry of the Codex backend catalog (`{"models":[...]}`). Only the +/// fields Kigi consumes are declared; the response carries many more. +#[derive(serde::Deserialize)] +struct CodexModel { + slug: String, + display_name: Option, + visibility: String, + supported_in_api: bool, + context_window: Option, + default_reasoning_level: Option, + #[serde(default)] + supported_reasoning_levels: Vec, + #[serde(default)] + input_modalities: Vec, +} + +#[derive(serde::Deserialize)] +struct CodexReasoningLevel { + effort: String, +} + +#[derive(serde::Deserialize)] +struct CodexListing { + models: Vec, +} + +/// Parse the ChatGPT Codex backend catalog +/// (`GET {base}/models?client_version=…`). Keeps only the models the backend +/// both shows and serves (`visibility == "list"` AND `supported_in_api`), so a +/// hidden or API-less model (`gpt-reserve`, `codex-auto-review`) never reaches +/// the picker. Context window and the per-model reasoning levels ride the wire; +/// an effort Kigi does not know (`ultra`) is dropped downstream by +/// `think_efforts_to_options`. +pub fn parse_openai_codex_listing(json: &str) -> Result, serde_json::Error> { + let listing: CodexListing = serde_json::from_str(json)?; + let kept = listing + .models + .into_iter() + .filter(|m| m.visibility == "list" && m.supported_in_api) + .map(|m| { + let efforts: Vec = m + .supported_reasoning_levels + .into_iter() + .map(|l| l.effort) + .collect(); + let supports_image_in = m.input_modalities.iter().any(|kind| kind == "image"); + WireModel { + id: m.slug, + context_length: m.context_window.unwrap_or_default(), + supports_reasoning: !efforts.is_empty(), + supports_image_in, + supports_video_in: false, + display_name: m.display_name, + max_output_tokens: 0, + supports_thinking_type: None, + think_efforts: (!efforts.is_empty()).then_some(WireThinkEfforts { + support: true, + valid_efforts: efforts, + default_effort: m.default_reasoning_level, + }), + } + }) + .collect(); + Ok(kept) +} + // Bundled offline fallback catalog /// The raw JSON, embedded at compile time. OFFLINE LAST RESORT: consulted only @@ -2653,90 +2646,47 @@ mod tests { assert_eq!(c.base_url(), "https://mock.codex/codex"); } - /// The HARDCODED openai-codex catalog is exactly the 4 supported+listed - /// models, keyed by slug, ctx 272000, each exposing its exact supported - /// efforts (incl. the codex-only `xhigh`/`max` tiers). The - /// list-but-broken / hidden models are absent. Every other platform serves - /// NO hardcoded catalog (its models come from the live wire). + /// The Codex catalog is LIVE. The parser keeps only the models the backend + /// shows AND serves, carries the wire context window, and passes every + /// advertised effort through (`ultra` included — the shell drops what it + /// cannot map). Body captured from + /// `GET chatgpt.com/backend-api/codex/models?client_version=0.155.1`, plus + /// `gpt-5.3-codex-spark` — the listed-but-`supported_in_api:false` shape the + /// backend served until recently, which `/responses` rejects. #[test] - fn openai_codex_hardcoded_catalog_is_the_four_supported_models() { - let catalog = PlatformId::OpenaiCodex - .hardcoded_catalog() - .expect("openai-codex serves a hardcoded catalog"); - let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect(); + fn openai_codex_listing_keeps_listed_api_served_models() { + const BODY: &str = r#"{"models":[{"slug":"gpt-6-astra","display_name":"GPT-6-Astra","visibility":"list","supported_in_api":true,"context_window":272000,"default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"},{"effort":"ultra"}],"input_modalities":["text","image"]},{"slug":"gpt-reserve","display_name":"GPT-Reserve","visibility":"hide","supported_in_api":true,"context_window":272000,"default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"}],"input_modalities":["text","image"]},{"slug":"gpt-5.3-codex-spark","display_name":"GPT-5.3-Codex-Spark","visibility":"list","supported_in_api":false,"context_window":272000,"default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"}],"input_modalities":["text","image"]},{"slug":"gpt-5.5","display_name":"GPT-5.5","visibility":"list","supported_in_api":true,"context_window":272000,"default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"}],"input_modalities":["text","image"]}]}"#; + let models = parse_openai_codex_listing(BODY).expect("codex listing parses"); + let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); assert_eq!( ids, - vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"], - "exactly the 4 visibility=list AND supported_in_api=true models" + vec!["gpt-6-astra", "gpt-5.5"], + "the hidden model and the listed-but-not-API-served one are dropped" ); - // Excluded: list-but-not-served + hidden models never appear. - for absent in [ - "gpt-5.3-codex-spark", - "gpt-5.4", - "gpt-5.4-mini", - "codex-auto-review", - ] { - assert!( - !ids.contains(&absent), - "{absent} must be excluded from the hardcoded catalog" - ); - } - for m in &catalog { - assert_eq!(m.context_length, 272_000, "{} ctx", m.id); - assert!(m.supports_reasoning && m.supports_image_in, "{} caps", m.id); - let caps = m.capabilities(); - assert!(caps.contains(&ModelCapability::Thinking)); - assert!(caps.contains(&ModelCapability::ImageIn)); - } - // Per-model efforts (the crux of "their thinking method"). - let efforts = |slug: &str| -> Vec { - catalog - .iter() - .find(|m| m.id == slug) - .unwrap() + + let astra = &models[0]; + assert_eq!(astra.display_name.as_deref(), Some("GPT-6-Astra")); + assert_eq!(astra.context_length, 272_000); + assert!(astra.supports_reasoning && astra.supports_image_in); + assert!(!astra.supports_video_in); + let caps = astra.capabilities(); + assert!(caps.contains(&ModelCapability::Thinking)); + assert!(caps.contains(&ModelCapability::ImageIn)); + let efforts = astra.think_efforts.as_ref().expect("astra efforts"); + assert!(efforts.support); + assert_eq!( + efforts.valid_efforts, + ["low", "medium", "high", "xhigh", "max", "ultra"] + ); + assert_eq!(efforts.default_effort.as_deref(), Some("medium")); + assert_eq!( + models[1] .think_efforts .as_ref() - .unwrap() - .valid_efforts - .clone() - }; - assert_eq!( - efforts("gpt-5.6-sol"), - ["low", "medium", "high", "xhigh", "max"] + .expect("gpt-5.5 efforts") + .valid_efforts, + ["low", "medium", "high", "xhigh"] ); - assert_eq!( - efforts("gpt-5.6-terra"), - ["low", "medium", "high", "xhigh", "max"] - ); - assert_eq!( - efforts("gpt-5.6-luna"), - ["low", "medium", "high", "xhigh", "max"] - ); - assert_eq!(efforts("gpt-5.5"), ["low", "medium", "high", "xhigh"]); - // Default efforts per the model table. - let default = |slug: &str| -> Option { - catalog - .iter() - .find(|m| m.id == slug) - .unwrap() - .think_efforts - .as_ref() - .unwrap() - .default_effort - .clone() - }; - assert_eq!(default("gpt-5.6-sol").as_deref(), Some("low")); - assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium")); - // Only openai-codex has a hardcoded catalog; every wire platform is None. - for p in PlatformId::ALL { - if p != PlatformId::OpenaiCodex { - assert!( - p.hardcoded_catalog().is_none(), - "{} must not carry a hardcoded catalog", - p.as_str() - ); - } - } } /// The Copilot `/models` filter keeps ONLY openai-completions-served, diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index fadf424..96d13e3 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -1198,11 +1198,19 @@ pub const COPILOT_INITIATOR: &str = "user"; pub const CODEX_ORIGINATOR: &str = "codex_cli_rs"; /// `OpenAI-Beta` opt-in the Codex Responses endpoint requires. pub const CODEX_OPENAI_BETA: &str = "responses=experimental"; +/// Codex CLI version kigi presents to the Codex backend. The models catalog +/// (`/codex/models?client_version=`) is GATED on it: an old version answers +/// with a truncated model list (0.104.0 serves no user-facing model at all), +/// so this tracks a real upstream release (openai/codex rust-v0.155.1). +macro_rules! codex_client_version { + () => { + "0.155.1" + }; +} +pub const CODEX_CLIENT_VERSION: &str = codex_client_version!(); /// `User-Agent` presented on the Codex path (overrides the default kigi UA, -/// openai-codex-gated). The Codex backend does not strictly validate the UA -/// string (Pi ships its own and it works), so this is a stable best-effort -/// identity, not a pinned build. -pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.104.0"; +/// openai-codex-gated), carrying the same pinned version. +pub const CODEX_USER_AGENT: &str = concat!("codex_cli_rs/", codex_client_version!()); /// JWT payload claim namespace carrying the ChatGPT account id. const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth"; @@ -1635,6 +1643,16 @@ mod tests { use super::*; use serde_json::json; + /// The catalog gate and the UA must claim the SAME codex version: the + /// backend serves a truncated model list to an old client_version. + #[test] + fn codex_user_agent_carries_the_pinned_client_version() { + assert_eq!( + CODEX_USER_AGENT, + format!("codex_cli_rs/{CODEX_CLIENT_VERSION}") + ); + } + /// The Codex backend rejects `role: system` input outright /// (400 `{"detail":"System messages are not allowed"}`) — its system /// channel is the top-level `instructions` field, and stateless diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 2c6c638..ee1c7f9 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -363,19 +363,6 @@ fn fetch_one_platform_models( bearer: &str, enrichment: &kigi_models::enrichment::EnrichmentCatalog, ) -> Result<(Vec, Option), BackendError> { - // A platform that serves NO live `/models` listing (openai-codex) delivers a - // HARDCODED catalog: short-circuit BEFORE any HTTP, mapping the compiled-in - // `WireModel`s through the SAME `platform_wire_model_to_entry` output a live - // listing produces (context window + per-model reasoning efforts). The - // bearer is unused here (login gates availability; no request is made). - if let Some(wire_models) = platform.hardcoded_catalog() { - let base_url = platform_fetch_base(platform, endpoints); - let models = wire_models - .into_iter() - .map(|wire| platform_wire_model_to_entry(platform, wire, &base_url)) - .collect(); - return Ok((models, None)); - } let client = crate::http::shared_blocking_client(); let url = match platform.listing() { kigi_models::ListingDialect::OpenAi => platform_models_url(platform, endpoints), @@ -384,6 +371,13 @@ fn fetch_one_platform_models( kigi_models::ListingDialect::Anthropic => { format!("{}?limit=1000", platform_models_url(platform, endpoints)) } + // The Codex backend REQUIRES client_version (400 without it) and gates + // the catalog on it: an old version is answered with a truncated list. + kigi_models::ListingDialect::OpenaiCodex => format!( + "{}?client_version={}", + platform_models_url(platform, endpoints), + kigi_sampling_types::CODEX_CLIENT_VERSION + ), }; tracing::info!(platform = platform.as_str(), url = %url, "fetching platform models"); let request = match platform.key_header() { @@ -476,6 +470,18 @@ fn fetch_one_platform_models( } })? } + // The Codex backend serves `{models:[...]}` with its own availability + // fields; the adapter keeps the shown + API-served models and carries + // context window and per-model reasoning levels. + kigi_models::ListingDialect::OpenaiCodex => { + let body = response.text()?; + kigi_models::parse_openai_codex_listing(&body).map_err(|e| { + BackendError::RequestFailed { + status: 200, + body: format!("codex listing parse failed: {e}"), + } + })? + } }; // Canonicalize listing ids before filtering/enrichment/keying. Google's // OpenAI-compat `/models` returns `models/`-prefixed ids while its chat @@ -1471,25 +1477,36 @@ mod tests { ); } - /// openai-codex fetch: the catalog is HARDCODED, so the fetch path - /// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet - /// the fetch returns exactly the 4 compiled-in models keyed - /// `openai-codex/` on the Responses backend, ctx 272000, each exposing - /// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`). - /// A BOGUS base URL confirms no live `/models` request is attempted (it would - /// otherwise fail against an unroutable host). + /// openai-codex e2e: the catalog is LIVE. The request carries the pinned + /// `client_version` (the backend 400s without it and truncates the list for + /// an old one) and the session bearer; the hidden model is dropped, and the + /// kept ones are keyed `openai-codex/` on the Responses backend with + /// their wire context window and efforts (`ultra`, which Kigi has no level + /// for, is dropped). #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] - async fn openai_codex_catalog_is_hardcoded_with_no_http_fetch() { - // Unroutable base: if the fetch path tried a live `/models` request it - // would error here; the hardcoded short-circuit ignores it for fetching. - let _base = kigi_test_support::EnvGuard::set( - kigi_models::CODEX_BASE_URL_ENV, - "http://127.0.0.1:1/codex", - ); + async fn openai_codex_catalog_comes_from_the_live_listing() { + const BODY: &str = r#"{"models":[{"slug":"gpt-6-astra","display_name":"GPT-6-Astra","visibility":"list","supported_in_api":true,"context_window":272000,"default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"},{"effort":"ultra"}],"input_modalities":["text","image"]},{"slug":"gpt-reserve","display_name":"GPT-Reserve","visibility":"hide","supported_in_api":true,"context_window":272000,"default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"}],"input_modalities":["text","image"]},{"slug":"gpt-5.5","display_name":"GPT-5.5","visibility":"list","supported_in_api":true,"context_window":272000,"default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"}],"input_modalities":["text","image"]}]}"#; + let codex_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + .and(wiremock::matchers::query_param( + "client_version", + kigi_sampling_types::CODEX_CLIENT_VERSION, + )) + .and(wiremock::matchers::header( + "Authorization", + "Bearer codex-session-tok", + )) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_raw(BODY, "application/json"), + ) + .expect(1) + .mount(&codex_server) + .await; + let _base = + kigi_test_support::EnvGuard::set(kigi_models::CODEX_BASE_URL_ENV, codex_server.uri()); let endpoints = crate::agent::config::EndpointsConfig::default(); - // The session token merely marks openai-codex "enabled"; it is unused by - // the hardcoded path (no request rides it). let mut oauth_tokens = OAuthSessionTokens::new(); oauth_tokens.insert( kigi_models::PlatformId::OpenaiCodex, @@ -1501,7 +1518,7 @@ mod tests { }) .await .unwrap() - .expect("openai-codex hardcoded catalog fetch must succeed with no HTTP"); + .expect("openai-codex listing fetch must succeed"); assert_eq!( result @@ -1509,57 +1526,35 @@ mod tests { .iter() .map(|m| m.id.as_deref().unwrap_or_default()) .collect::>(), - vec![ - "openai-codex/gpt-5.6-sol", - "openai-codex/gpt-5.6-terra", - "openai-codex/gpt-5.6-luna", - "openai-codex/gpt-5.5", - ], - "exactly the 4 hardcoded models, keyed openai-codex/" + vec!["openai-codex/gpt-6-astra", "openai-codex/gpt-5.5"], + "hidden models never reach the picker" ); - // Excluded models never appear. - for absent in [ - "openai-codex/gpt-5.3-codex-spark", - "openai-codex/gpt-5.4", - "openai-codex/gpt-5.4-mini", - "openai-codex/codex-auto-review", - ] { - assert!( - !result - .models - .iter() - .any(|m| m.id.as_deref() == Some(absent)), - "{absent} must be absent from the hardcoded catalog" - ); - } - let sol = &result.models[0]; + let astra = &result.models[0]; assert_eq!( - sol.api_backend, + astra.api_backend, crate::sampling::ApiBackend::Responses, "openai-codex speaks the Responses wire" ); - assert_eq!(sol.context_window.get(), 272_000); - assert_eq!(sol.name.as_deref(), Some("GPT-5.6-Sol")); + assert_eq!(astra.context_window.get(), 272_000); + assert_eq!(astra.name.as_deref(), Some("GPT-6-Astra")); assert!( - sol.supported_in_api, - "openai-codex carries its OWN pooled credential — it must NOT be \ - gated on the primary (Kimi) session" + astra.supported_in_api, + "openai-codex carries its OWN pooled credential — it must NOT be gated on the primary (Kimi) session" ); assert!( - visible_to_non_primary_session_user(sol), - "a ChatGPT/Codex-only user has no primary OAuth session; their \ - models must still appear in the picker" + visible_to_non_primary_session_user(astra), + "a ChatGPT/Codex-only user has no primary OAuth session; their models must still appear in the picker" ); - assert!(sol.supports_reasoning_effort); + assert!(astra.supports_reasoning_effort); assert_eq!( - sol.reasoning_efforts + astra + .reasoning_efforts .iter() .map(|o| o.id.as_str()) .collect::>(), vec!["low", "medium", "high", "xhigh", "max"], - "sol exposes the full codex effort menu" + "`ultra` has no Kigi level and is dropped" ); - // gpt-5.5 tops out at xhigh (no max). let five_five = result .models .iter()