feat(models): fetch the openai-codex catalog live
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=<v>` 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.
This commit is contained in:
4 files changed
+213
-240
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ pub enum ListingDialect {
|
||||
/// headers, Anthropic's response shape (parsed by
|
||||
/// [`parse_anthropic_listing`]).
|
||||
Anthropic,
|
||||
/// `GET {base}/models?client_version=<codex CLI 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<Vec<WireModel>> {
|
||||
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<WireModel> {
|
||||
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<Vec<WireModel>, 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<String>,
|
||||
visibility: String,
|
||||
supported_in_api: bool,
|
||||
context_window: Option<u64>,
|
||||
default_reasoning_level: Option<String>,
|
||||
#[serde(default)]
|
||||
supported_reasoning_levels: Vec<CodexReasoningLevel>,
|
||||
#[serde(default)]
|
||||
input_modalities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CodexReasoningLevel {
|
||||
effort: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CodexListing {
|
||||
models: Vec<CodexModel>,
|
||||
}
|
||||
|
||||
/// 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<Vec<WireModel>, 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<String> = 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<String> {
|
||||
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<String> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -363,19 +363,6 @@ fn fetch_one_platform_models(
|
||||
bearer: &str,
|
||||
enrichment: &kigi_models::enrichment::EnrichmentCatalog,
|
||||
) -> Result<(Vec<crate::agent::config::ModelEntryConfig>, Option<String>), 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/<slug>` 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/<slug>` 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<_>>(),
|
||||
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/<slug>"
|
||||
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<_>>(),
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user