revert: openai-codex goes back to the compiled-in catalog

Reverts c61977c. The live listing needed no codex CLI on the user's
machine — kigi issued the request itself and `client_version` was a
compiled-in string — but the project keeps the catalog in the binary, so
the fetch path, the `OpenaiCodex` listing dialect, its parser and the
pinned client version are gone again, and the codex `User-Agent` returns
to its previous value.

The models.dev provider rename `kimi-for-coding` → `kimi-code-plan-cn`
rode in the same commit and stays: the regenerated snapshot ships under
the new id.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-21 18:36:46 -04:00
1 parent 95d4f19650
commit a690987ea2
4 files changed
+238 -211

No files matched your search

+158 -108
View File
@@ -61,11 +61,6 @@ 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
@@ -1272,12 +1267,17 @@ const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec {
vendor: "OpenAI",
console_host: Some("chatgpt.com"),
login_label: Some("ChatGPT Plus/Pro (Codex)"),
// The Codex backend serves its own catalog with context + per-model
// reasoning levels, so models.dev enrichment is neither used nor needed.
// HARDCODED catalog (see `hardcoded_catalog`): NOT enriched from models.dev
// and NOT live-fetched — OpenAI exposes no stable public models endpoint for
// this backend.
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,
listing: ListingDialect::OpenaiCodex,
// Unused: the catalog does NOT come from a live `/models` listing (the
// fetch path short-circuits to `hardcoded_catalog`).
listing: ListingDialect::OpenAi,
chat_compat: PlatformChatCompat::Passthrough,
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
@@ -1566,6 +1566,79 @@ 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
@@ -2011,72 +2084,6 @@ 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
@@ -2646,47 +2653,90 @@ mod tests {
assert_eq!(c.base_url(), "https://mock.codex/codex");
}
/// 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.
/// 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).
#[test]
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();
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();
assert_eq!(
ids,
vec!["gpt-6-astra", "gpt-5.5"],
"the hidden model and the listed-but-not-API-served one are dropped"
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"
);
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]
// 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()
.think_efforts
.as_ref()
.expect("gpt-5.5 efforts")
.valid_efforts,
["low", "medium", "high", "xhigh"]
.unwrap()
.valid_efforts
.clone()
};
assert_eq!(
efforts("gpt-5.6-sol"),
["low", "medium", "high", "xhigh", "max"]
);
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,19 +1198,11 @@ 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), carrying the same pinned version.
pub const CODEX_USER_AGENT: &str = concat!("codex_cli_rs/", codex_client_version!());
/// 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";
/// JWT payload claim namespace carrying the ChatGPT account id.
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
@@ -1643,16 +1635,6 @@ 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,6 +363,19 @@ 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),
@@ -371,13 +384,6 @@ 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() {
@@ -470,18 +476,6 @@ 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
@@ -1477,36 +1471,25 @@ mod tests {
);
}
/// 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).
/// 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).
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
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());
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",
);
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,
@@ -1518,7 +1501,7 @@ mod tests {
})
.await
.unwrap()
.expect("openai-codex listing fetch must succeed");
.expect("openai-codex hardcoded catalog fetch must succeed with no HTTP");
assert_eq!(
result
@@ -1526,35 +1509,57 @@ mod tests {
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["openai-codex/gpt-6-astra", "openai-codex/gpt-5.5"],
"hidden models never reach the picker"
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>"
);
let astra = &result.models[0];
// 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];
assert_eq!(
astra.api_backend,
sol.api_backend,
crate::sampling::ApiBackend::Responses,
"openai-codex speaks the Responses wire"
);
assert_eq!(astra.context_window.get(), 272_000);
assert_eq!(astra.name.as_deref(), Some("GPT-6-Astra"));
assert_eq!(sol.context_window.get(), 272_000);
assert_eq!(sol.name.as_deref(), Some("GPT-5.6-Sol"));
assert!(
astra.supported_in_api,
"openai-codex carries its OWN pooled credential — it must NOT be gated on the primary (Kimi) session"
sol.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(astra),
"a ChatGPT/Codex-only user has no primary OAuth session; their models must still appear in the picker"
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"
);
assert!(astra.supports_reasoning_effort);
assert!(sol.supports_reasoning_effort);
assert_eq!(
astra
.reasoning_efforts
sol.reasoning_efforts
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
vec!["low", "medium", "high", "xhigh", "max"],
"`ultra` has no Kigi level and is dropped"
"sol exposes the full codex effort menu"
);
// gpt-5.5 tops out at xhigh (no max).
let five_five = result
.models
.iter()