From 422e241e1338a9d048b3e0eccfa34c672746300a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 22 Jul 2026 08:28:03 -0400 Subject: [PATCH] feat(providers): add ChatGPT/Codex subscription OAuth (PKCE + account-id, hardcoded catalog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 29th platform `openai-codex` (uses_oauth, Responses wire). PKCE-localhost login at auth.openai.com (client app_EMoamEEZ73f0CkXaXp7hrann, redirect localhost:1455/auth/ callback, form token exchange, fresh-random state) reusing the claude-pro-max flow; OAuthFlow::PkceLocalhost gained a redirect_path and OAuthConfig an authorize_extra (empty elsewhere, so claude/xai/copilot authorize URLs stay byte-identical). Codex-specific: the access token is a JWT carrying chatgpt_account_id, which becomes the `chatgpt-account-id` inference header. It is derived STATELESSLY from whichever bearer rides each request (so a rotated token needs no persisted field), and BOTH login and refresh fail fast when the claim is absent — gated on the explicit OAuthConfig.requires_chatgpt_account_id fact, never inferred from the token-body encoding (a plain form endpoint is the OAuth norm and must not inherit this). Inference rides the existing Responses wire at chatgpt.com/backend-api/codex → /responses, with codex headers (chatgpt-account-id, originator, OpenAI-Beta responses=experimental, codex UA) gated on SamplerConfig.openai_codex so API-key `openai` stays byte-identical; store:false was already the global Responses default. Catalog is HARDCODED (no live endpoint exists for this backend; read from the official Codex CLI's model cache): gpt-5.6-sol/terra/luna + gpt-5.5, ctx 272000, each with its real reasoning levels (low..ultra — ReasoningEffort gained Ultra). Excluded: gpt-5.3-codex-spark (supported_in_api=false), gpt-5.4/-mini and codex-auto-review (hidden) — they would list but fail at inference. The fetch short-circuits before any HTTP; Kigi never shells out to the codex CLI or reads ~/.codex. Security review fixes: redact any `account-id` header from request logs (it was reaching debug logs), strict 3-segment JWT check (fail closed), refresh no longer fails open on a missing claim. Inherits leak-safe pooled routing (scope oauth/openai-codex) — never the Kimi token. Full gate green (234 suites, 0 warnings). --- AGENTS.md | 63 ++- Cargo.lock | 1 + crates/codegen/kigi-models/src/lib.rs | 366 +++++++++++++++++- .../codegen/kigi-sampler/src/actor/state.rs | 1 + crates/codegen/kigi-sampler/src/client.rs | 150 +++++++ crates/codegen/kigi-sampler/src/config.rs | 9 + .../codegen/kigi-sampler/tests/test_actor.rs | 1 + crates/codegen/kigi-sampling-types/Cargo.toml | 1 + .../codegen/kigi-sampling-types/src/types.rs | 150 ++++++- .../kigi-shell/src/agent/auth_method.rs | 9 + crates/codegen/kigi-shell/src/agent/config.rs | 10 + .../kigi-shell/src/agent/models_fetch.rs | 111 ++++++ .../kigi-shell/src/agent/session_config.rs | 1 + .../kigi-shell/src/agent/subagent/mod.rs | 6 + crates/codegen/kigi-shell/src/auth/flow.rs | 74 +++- .../codegen/kigi-shell/src/auth/oauth_pkce.rs | 289 ++++++++++++-- .../kigi-shell/src/auth/oauth_registry.rs | 40 ++ .../src/auth/refresh/generic_refresher.rs | 25 +- .../session/acp_session_impl/sampler_turn.rs | 13 + .../auth_error_no_retry_tests.rs | 1 + .../cancel_running_task_tests.rs | 4 + .../src/session/helpers/session_compact.rs | 1 + .../src/test_support/lsp_runtime.rs | 1 + crates/codegen/kigi-shell/tests/common/mod.rs | 1 + crates/codegen/kigi-tui/src/app/app_view.rs | 67 ++-- .../src/slash/commands/effort_levels.rs | 1 + .../codegen/kigi-tui/src/views/welcome/mod.rs | 4 + 27 files changed, 1294 insertions(+), 106 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72562f2..432aed0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,17 +182,50 @@ edges stay deterministic Rust. The harness appends a terminal - `OAuthFlow::DeviceCode` → `auth::oauth_device` (RFC-8628 device-code, plain kigi UA, no X-Msh headers). Provider: `xai-grok` (`scope_key oauth/xai`, base `api.x.ai/v1`, form token body, same wire as the API-key `xai` row). - - `OAuthFlow::PkceLocalhost { redirect_port }` → `auth::oauth_pkce` - (authorization-code + PKCE S256, `127.0.0.1:redirect_port/callback` loopback - with STRICT `state` validation + manual-paste fallback, JSON token body, - authorize host ≠ token host). Provider: `claude-pro-max` (`scope_key - oauth/claude-pro-max`, base `api.anthropic.com/v1`, Anthropic Messages + - listing wire reached with an OAuth `sk-ant-oat…` Bearer). Its Messages - requests take the OAuth adaptation — `anthropic-beta claude-code-…,oauth-…` - + `claude-cli` UA + `x-app cli` + the required "You are Claude Code…" system - prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max only), so - API-key `anthropic`/`minimax` Messages requests stay byte-identical. Its - `/v1/models` listing rides the same Bearer + oauth-beta headers. + - `OAuthFlow::PkceLocalhost { redirect_port, redirect_path }` → + `auth::oauth_pkce` (authorization-code + PKCE S256, + `127.0.0.1:redirect_port{redirect_path}` loopback with STRICT `state` + validation + manual-paste fallback, authorize host possibly ≠ token host). + The `token_body` selects the login dialect: `Json` = claude (`state == + verifier`, JSON exchange carrying `state`), `Form` = codex (fresh-random + `state`, FORM exchange without `state` via `exchange_code_form`). + `OAuthConfig.authorize_extra` appends provider-only authorize params (empty + for all but codex). Providers: + - `claude-pro-max` (`scope_key oauth/claude-pro-max`, port 53692 + `/callback`, JSON body, base `api.anthropic.com/v1`, Anthropic Messages + + listing wire reached with an OAuth `sk-ant-oat…` Bearer). Its Messages + requests take the OAuth adaptation — `anthropic-beta claude-code-…,oauth-…` + + `claude-cli` UA + `x-app cli` + the required "You are Claude Code…" + system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max + only), so API-key `anthropic`/`minimax` Messages requests stay + byte-identical. Its `/v1/models` listing rides the same Bearer + + oauth-beta headers. + - `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port + 1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`, + client `app_EMoam…`, scope `openid profile email offline_access`, the 3 + authorize-extra params `id_token_add_organizations`/ + `codex_cli_simplified_flow`/`originator=codex_cli_rs`). Refresh is a plain + `refresh_token` FORM grant (the generic refresher's `Form` path → + `auth::oauth_device`). The minted `access_token` is a JWT; login FAILS + FAST unless it carries the `["https://api.openai.com/auth"] + ["chatgpt_account_id"]` claim (`chatgpt_account_id_from_jwt`) — that + account id is NOT persisted but re-derived STATELESSLY from the current + bearer at every request. INFERENCE reuses the EXISTING Responses wire + against base `chatgpt.com/backend-api/codex` (→ `{base}/responses`) with + a Codex-gated adaptation (`SamplerConfig.openai_codex` / + `PlatformId::sends_codex_responses_headers()`): headers + `chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`, + `OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false` + is the shared Responses default. API-key `openai` Responses requests carry + NONE of this (byte-identical). `reasoning.effort` carries the thinking + level (incl. the codex-only `ultra`). 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. - `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 @@ -222,7 +255,7 @@ edges stay deterministic Rust. The harness appends a terminal state and is unverifiable without a live Copilot account), not a silent gap. These are INTERACTIVE login rows advertised right after `kimi-code` (`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok`, - `claude-pro-max`, `github-copilot`). The catalog fetch resolves each such platform's OWN session + `claude-pro-max`, `github-copilot`, `openai-codex`). The catalog fetch resolves each such platform's OWN session token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes `platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone → `proxy_url()`). Tokens/codes/verifiers are NEVER logged. @@ -233,8 +266,10 @@ edges stay deterministic Rust. The harness appends a terminal `parse_api_json` for bundled + runtime refresh; 24h cache `~/.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; Kimi wire spells its top tier `max`, kimi_compat renames). + none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh` + 2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's + server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat + renames). ## Milestones (PRD §8.3) diff --git a/Cargo.lock b/Cargo.lock index 0de5a28..206272f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6158,6 +6158,7 @@ version = "0.1.3" dependencies = [ "assert_matches", "async-openai", + "base64", "indexmap", "kigi-compaction", "kigi-tools", diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 0f0a884..92e262d 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -107,8 +107,12 @@ pub enum OAuthFlow { DeviceCode, /// Authorization-code + PKCE (S256): browser hits `auth_host`+`device_path` /// (the authorize endpoint); the code returns to a `127.0.0.1:redirect_port` - /// loopback listener, then is exchanged at `token_host`+`token_path`. - PkceLocalhost { redirect_port: u16 }, + /// loopback listener answering `redirect_path` (claude `/callback`, codex + /// `/auth/callback`), then is exchanged at `token_host`+`token_path`. + PkceLocalhost { + redirect_port: u16, + redirect_path: &'static str, + }, /// GitHub Copilot two-stage flow (github-copilot): an RFC-8628 device flow /// on `auth_host` (github.com) mints the DURABLE GitHub token, which is then /// exchanged at `copilot_exchange` for the SHORT-LIVED copilot session @@ -167,6 +171,11 @@ pub struct OAuthConfig { /// A non-standard extra form field sent ONLY on the device-authorization /// request (e.g. `("referrer", "kigi")`). `None` = no extra field. pub extra_device_field: Option<(&'static str, &'static str)>, + /// Extra query params appended ONLY to the `PkceLocalhost` browser authorize + /// URL (openai-codex's `id_token_add_organizations`, `codex_cli_simplified_ + /// flow`, `originator`). Empty for every other config, so their authorize + /// URLs stay byte-identical. + pub authorize_extra: &'static [(&'static str, &'static str)], /// Interactive login mechanism (device-code vs PKCE-localhost). pub flow: OAuthFlow, /// Body encoding the token endpoint expects (form vs JSON). @@ -178,6 +187,13 @@ pub struct OAuthConfig { /// "refresh". `None` for the standard flows (xai-grok, claude-pro-max), /// whose refresh is a plain `refresh_token` grant against `token_host`. pub copilot_exchange: Option<(&'static str, &'static str)>, + /// The access token MUST carry a `chatgpt_account_id` claim (openai-codex): + /// it becomes the `chatgpt-account-id` inference header, so a token without + /// it cannot authorize a request. Login AND every refresh fail fast when the + /// claim is absent. `false` everywhere else — this is an explicit provider + /// fact, NEVER inferred from the token-body encoding (a plain form-encoded + /// token endpoint is the OAuth norm and must not inherit this requirement). + pub requires_chatgpt_account_id: bool, } /// xAI / Grok subscription device-code OAuth (ported from Pi @@ -191,9 +207,11 @@ pub const XAI_OAUTH_CONFIG: OAuthConfig = OAuthConfig { scope: "openid profile email offline_access grok-cli:access api:access", scope_key: "oauth/xai", extra_device_field: Some(("referrer", "kigi")), + authorize_extra: &[], flow: OAuthFlow::DeviceCode, token_body: OAuthTokenBody::Form, copilot_exchange: None, + requires_chatgpt_account_id: false, }; /// Base-URL override for the Claude Pro/Max OAuth channel (dev/test escape @@ -213,11 +231,14 @@ pub const CLAUDE_OAUTH_CONFIG: OAuthConfig = OAuthConfig { scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload", scope_key: "oauth/claude-pro-max", extra_device_field: None, + authorize_extra: &[], flow: OAuthFlow::PkceLocalhost { redirect_port: 53692, + redirect_path: "/callback", }, token_body: OAuthTokenBody::Json, copilot_exchange: None, + requires_chatgpt_account_id: false, }; /// Base-URL override for the GitHub Copilot inference/listing channel @@ -240,9 +261,49 @@ pub const COPILOT_OAUTH_CONFIG: OAuthConfig = OAuthConfig { scope: "read:user", scope_key: "oauth/github-copilot", extra_device_field: None, + authorize_extra: &[], flow: OAuthFlow::GithubDeviceCopilot, token_body: OAuthTokenBody::GithubCopilotExchange, copilot_exchange: Some(("https://api.github.com", "/copilot_internal/v2/token")), + requires_chatgpt_account_id: false, +}; + +/// Base-URL override for the ChatGPT/Codex OAuth inference channel (dev/test +/// escape hatch). Production defaults to the ChatGPT Codex backend +/// `https://chatgpt.com/backend-api/codex`; Kigi's Responses path posts to +/// `{base}/responses`. +pub const CODEX_BASE_URL_ENV: &str = "KIGI_CODEX_BASE_URL"; + +/// ChatGPT/Codex subscription OAuth (authorization-code + PKCE S256, loopback +/// callback on port 1455 path `/auth/callback`, FORM token body). Authoritative +/// constants from the official Codex CLI + Pi `earendil-works/pi` +/// `auth/oauth/openai-codex.ts`: authorize + token host `https://auth.openai.com`, +/// the `codex_cli_simplified_flow` login client id, and the three authorize-only +/// extra params. Unlike claude the `state` is fresh-random (NOT the verifier), +/// and refresh is a plain `refresh_token` FORM grant (the generic device +/// refresher's `Form` path). The minted `access_token` is a JWT carrying the +/// `chatgpt_account_id` claim consumed at inference time. +pub const CODEX_OAUTH_CONFIG: OAuthConfig = OAuthConfig { + client_id: "app_EMoamEEZ73f0CkXaXp7hrann", + auth_host: "https://auth.openai.com", + device_path: "/oauth/authorize", + token_host: "https://auth.openai.com", + token_path: "/oauth/token", + scope: "openid profile email offline_access", + scope_key: "oauth/openai-codex", + extra_device_field: None, + authorize_extra: &[ + ("id_token_add_organizations", "true"), + ("codex_cli_simplified_flow", "true"), + ("originator", "codex_cli_rs"), + ], + flow: OAuthFlow::PkceLocalhost { + redirect_port: 1455, + redirect_path: "/auth/callback", + }, + token_body: OAuthTokenBody::Form, + copilot_exchange: None, + requires_chatgpt_account_id: true, }; /// The generic device-code OAuth config for a platform, or `None` for API-key @@ -1184,6 +1245,41 @@ const GITHUB_COPILOT_SPEC: PlatformSpec = PlatformSpec { strip_listing_id_prefix: None, }; +const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec { + id: "openai-codex", + display_name: "ChatGPT (Codex)", + // Kigi's Responses path posts to `{base}/responses`; the codex backend + // serves it at `.../codex/responses`, so the base carries the `/codex` tail. + base_url: BaseUrlSource::EnvOr { + env: CODEX_BASE_URL_ENV, + default: "https://chatgpt.com/backend-api/codex", + }, + uses_oauth: true, + oauth: Some(&CODEX_OAUTH_CONFIG), + allowed_model_prefixes: None, + // OAuth channel: no API key envs (the PKCE session is the bearer). + api_key_envs: &[], + 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. + 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, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + restrict_to_enriched: false, + key_validation_path: None, + strip_listing_id_prefix: None, +}; + /// The platform registry. Platforms are compiled-in spec rows; there is no /// dynamic provider registration (PRD F2). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -1246,12 +1342,16 @@ pub enum PlatformId { /// GitHub Copilot subscription via the two-stage device-code OAuth flow /// (ChatCompletions wire reached with the short-lived copilot token). GithubCopilot, + /// ChatGPT Plus/Pro subscription via PKCE-localhost OAuth against the + /// ChatGPT Codex backend (Responses wire reached with an OAuth bearer + + /// the `chatgpt-account-id` JWT claim). HARDCODED catalog, no live listing. + OpenaiCodex, } impl PlatformId { /// All platforms, in catalog precedence order: the subscription channel /// first so "default model = first list item" favors it when present. - pub const ALL: [PlatformId; 28] = [ + pub const ALL: [PlatformId; 29] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -1280,6 +1380,7 @@ impl PlatformId { Self::XaiGrok, Self::ClaudeProMax, Self::GithubCopilot, + Self::OpenaiCodex, ]; /// The registry row backing this platform (single source of per-platform @@ -1314,6 +1415,7 @@ impl PlatformId { Self::XaiGrok => &XAI_GROK_SPEC, Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC, Self::GithubCopilot => &GITHUB_COPILOT_SPEC, + Self::OpenaiCodex => &OPENAI_CODEX_SPEC, } } @@ -1450,6 +1552,88 @@ impl PlatformId { pub fn sends_copilot_editor_headers(self) -> bool { matches!(self, Self::GithubCopilot) } + + /// True ONLY for `openai-codex`: its `/responses` inference request must + /// carry the Codex identity headers (`chatgpt-account-id` from the bearer + /// JWT, `originator: codex_cli_rs`, `OpenAI-Beta: responses=experimental`, a + /// codex `User-Agent`). The sampler gates these on this predicate, so the + /// API-key `openai` Responses requests stay byte-identical. + 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`/`ultra` + /// 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 intentionally 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", "ultra"], + "low", + ), + codex_wire_model( + "gpt-5.6-terra", + "GPT-5.6-Terra", + &["low", "medium", "high", "xhigh", "max", "ultra"], + "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 @@ -2288,7 +2472,8 @@ mod tests { assert_eq!( cfg.flow, OAuthFlow::PkceLocalhost { - redirect_port: 53692 + redirect_port: 53692, + redirect_path: "/callback", } ); assert_eq!(cfg.token_body, OAuthTokenBody::Json); @@ -2379,6 +2564,176 @@ mod tests { assert_eq!(g.base_url(), "https://mock.copilot"); } + /// openai-codex is the ChatGPT/Codex PKCE-localhost OAuth platform: it + /// carries a `PkceLocalhost{1455, "/auth/callback"}` / FORM `OAuthConfig` + /// with the 3 authorize-extra params, speaks the Responses wire with a Bearer + /// OAuth token + the codex-headers gate, and is NOT models.dev-enriched (its + /// catalog is hardcoded). + #[test] + fn openai_codex_is_a_pkce_responses_oauth_platform() { + let c = PlatformId::OpenaiCodex; + assert_eq!(c.as_str(), "openai-codex"); + assert!(c.uses_oauth()); + assert!( + c.sends_codex_responses_headers(), + "openai-codex must gate the Codex identity headers" + ); + // Every OTHER platform must NOT send the codex headers (regression). + for other in PlatformId::ALL { + if other != PlatformId::OpenaiCodex { + assert!( + !other.sends_codex_responses_headers(), + "{} must not send the codex responses headers", + other.as_str() + ); + } + } + let cfg = c.oauth().expect("openai-codex carries a PKCE OAuthConfig"); + assert_eq!(cfg, &CODEX_OAUTH_CONFIG); + assert_eq!(cfg.client_id, "app_EMoamEEZ73f0CkXaXp7hrann"); + assert_eq!(cfg.auth_host, "https://auth.openai.com"); + assert_eq!(cfg.token_host, "https://auth.openai.com"); + assert_eq!(cfg.device_path, "/oauth/authorize"); + assert_eq!(cfg.token_path, "/oauth/token"); + assert_eq!(cfg.scope, "openid profile email offline_access"); + assert_eq!(cfg.scope_key, "oauth/openai-codex"); + assert_eq!( + cfg.flow, + OAuthFlow::PkceLocalhost { + redirect_port: 1455, + redirect_path: "/auth/callback", + } + ); + // FORM token body (refresh routes through the generic device refresher). + assert_eq!(cfg.token_body, OAuthTokenBody::Form); + assert_eq!(cfg.copilot_exchange, None); + // The 3 authorize-only extra params; claude/xai/copilot carry none. + assert_eq!( + cfg.authorize_extra, + &[ + ("id_token_add_organizations", "true"), + ("codex_cli_simplified_flow", "true"), + ("originator", "codex_cli_rs"), + ] + ); + assert_eq!(CLAUDE_OAUTH_CONFIG.authorize_extra, &[] as &[(&str, &str)]); + assert_eq!(XAI_OAUTH_CONFIG.authorize_extra, &[] as &[(&str, &str)]); + assert_eq!(COPILOT_OAUTH_CONFIG.authorize_extra, &[] as &[(&str, &str)]); + // The chatgpt_account_id requirement is an EXPLICIT per-provider fact, + // never inferred from the token-body encoding — a future form-encoded + // PKCE provider must not inherit ChatGPT's account-id gate. Enforced at + // COMPILE time: adding a provider that flips this fails the build. + const { + assert!(CODEX_OAUTH_CONFIG.requires_chatgpt_account_id); + assert!(!CLAUDE_OAUTH_CONFIG.requires_chatgpt_account_id); + assert!(!XAI_OAUTH_CONFIG.requires_chatgpt_account_id); + assert!(!COPILOT_OAUTH_CONFIG.requires_chatgpt_account_id); + } + assert_eq!( + oauth_config_for_scope_key("oauth/openai-codex"), + Some(&CODEX_OAUTH_CONFIG) + ); + // Responses wire, Bearer, hardcoded (no models.dev id), own base. + assert_eq!(c.models_dev_id(), None); + assert_eq!(c.wire_api(), PlatformWireApi::Responses); + assert_eq!(c.key_header(), PlatformKeyHeader::Bearer); + assert_eq!(c.api_key_env_names(), &[] as &[&str]); + assert_eq!(c.managed_model_key("gpt-5.5"), "openai-codex/gpt-5.5"); + assert_eq!( + c.base_url(), + "https://chatgpt.com/backend-api/codex", + "default codex base carries the /codex tail (→ /codex/responses)" + ); + let _guard = kigi_env::EnvVarGuard::set(CODEX_BASE_URL_ENV, "https://mock.codex/codex"); + 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`/`ultra` 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_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-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" + ); + // 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() + .think_efforts + .as_ref() + .unwrap() + .valid_efforts + .clone() + }; + assert_eq!( + efforts("gpt-5.6-sol"), + ["low", "medium", "high", "xhigh", "max", "ultra"] + ); + assert_eq!( + efforts("gpt-5.6-terra"), + ["low", "medium", "high", "xhigh", "max", "ultra"] + ); + 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, /// selectable, tool-calling models — dropping the claude-4.x/5.x (messages) /// and gpt-5/oswe/mai- (responses-only) ids AND the disabled / picker-off / @@ -2464,9 +2819,10 @@ mod tests { PlatformId::XaiGrok => 25, PlatformId::ClaudeProMax => 26, PlatformId::GithubCopilot => 27, + PlatformId::OpenaiCodex => 28, } } - const VARIANT_COUNT: usize = 28; // update together with `ordinal` + const VARIANT_COUNT: usize = 29; // update together with `ordinal` let mut seen: Vec = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); seen.sort_unstable(); seen.dedup(); diff --git a/crates/codegen/kigi-sampler/src/actor/state.rs b/crates/codegen/kigi-sampler/src/actor/state.rs index 3e5df59..fd3f14f 100644 --- a/crates/codegen/kigi-sampler/src/actor/state.rs +++ b/crates/codegen/kigi-sampler/src/actor/state.rs @@ -91,6 +91,7 @@ mod tests { auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 8192, diff --git a/crates/codegen/kigi-sampler/src/client.rs b/crates/codegen/kigi-sampler/src/client.rs index 5211605..dfddf3d 100644 --- a/crates/codegen/kigi-sampler/src/client.rs +++ b/crates/codegen/kigi-sampler/src/client.rs @@ -304,6 +304,9 @@ struct ClientDefaults { doom_loop_recovery: Option, /// Claude Pro/Max OAuth Messages adaptation (see [`SamplerConfig`]). anthropic_oauth: bool, + /// ChatGPT/Codex Responses adaptation (see [`SamplerConfig`]). Gates the + /// per-request `chatgpt-account-id` header derived from the bearer JWT. + openai_codex: bool, } // ============================================================================= @@ -479,6 +482,24 @@ impl SamplingClient { ); } + // ChatGPT/Codex Responses identity headers (openai-codex only). The + // Codex backend authorizes the OAuth bearer AND validates the Codex + // client identity. The static pieces (`originator`, `OpenAI-Beta`) ride + // every request; `chatgpt-account-id` is dynamic (derived per-request + // from the bearer JWT in `post()`), and `User-Agent` is set in the UA + // block below. Gated on `openai_codex` so API-key `openai` Responses + // requests stay byte-identical (`store: false` is the shared default). + if config.openai_codex { + headers.insert( + HeaderName::from_static("originator"), + HeaderValue::from_static(kigi_sampling_types::CODEX_ORIGINATOR), + ); + headers.insert( + HeaderName::from_static("openai-beta"), + HeaderValue::from_static(kigi_sampling_types::CODEX_OPENAI_BETA), + ); + } + // Apply all extra headers verbatim. This is the single // injection point for proxy-auth headers and any other URL- or // environment-specific headers the session decides to set. @@ -503,6 +524,8 @@ impl SamplingClient { kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string() } else if config.github_copilot { kigi_sampling_types::COPILOT_USER_AGENT.to_string() + } else if config.openai_codex { + kigi_sampling_types::CODEX_USER_AGENT.to_string() } else { match config.origin_client.as_ref() { Some(origin) => user_agent_string_for(origin), @@ -551,6 +574,7 @@ impl SamplingClient { stream_tool_calls: config.stream_tool_calls, doom_loop_recovery: config.doom_loop_recovery, anthropic_oauth: config.anthropic_oauth, + openai_codex: config.openai_codex, }; Ok(Self { @@ -590,6 +614,23 @@ impl SamplingClient { } } } + // ChatGPT/Codex `chatgpt-account-id` (openai-codex only): derive it + // STATELESSLY from the bearer that will actually ride this request (the + // resolver-fresh one just set, or the construction-time bearer in + // `default_headers`) by decoding its JWT claim. A refreshed token still + // carries the claim, so there is no persisted account-id field. + // openai-codex-gated → API-key `openai` never gets this header. + // SECURITY: the bearer and account id are never logged here. + if self.defaults.openai_codex + && let Some(bearer) = headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + && let Some(account_id) = kigi_sampling_types::chatgpt_account_id_from_jwt(bearer) + && let Ok(v) = HeaderValue::from_str(&account_id) + { + headers.insert(HeaderName::from_static("chatgpt-account-id"), v); + } { let auth_prefix = headers .get(AUTHORIZATION) @@ -699,6 +740,9 @@ impl SamplingClient { || lower.contains("apikey") || lower.contains("token") || lower.contains("secret") + // `chatgpt-account-id` (Codex) and any other account identifier: a + // stable per-user id that must never reach a log. + || lower.contains("account-id") } /// Format a single header for error messages, redacting sensitive values. @@ -1990,6 +2034,7 @@ mod tests { auth_scheme: AuthScheme::Bearer, anthropic_oauth: false, github_copilot: false, + openai_codex: false, chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 8192, @@ -2148,6 +2193,29 @@ mod tests { ); } + /// SECURITY REGRESSION: `chatgpt-account-id` (the Codex per-user account + /// identifier) must be redacted by the header loggers. It is not a token, so + /// the substring list has to name it explicitly — without this, one debug + /// request log would write the user's stable ChatGPT account id to disk. + #[test] + fn account_id_headers_are_redacted_from_logs() { + assert!( + SamplingClient::is_sensitive_header("chatgpt-account-id"), + "chatgpt-account-id must be treated as sensitive" + ); + assert!( + SamplingClient::is_sensitive_header("ChatGPT-Account-Id"), + "redaction is case-insensitive" + ); + let rendered = SamplingClient::format_header("chatgpt-account-id", "acct-abc-123"); + assert!( + rendered.contains("[REDACTED]") && !rendered.contains("acct-abc-123"), + "the account id value must never appear verbatim, got {rendered:?}" + ); + // Sanity: an ordinary header is still shown (redaction stays targeted). + assert!(!SamplingClient::is_sensitive_header("content-type")); + } + /// REGRESSION: a plain ChatCompletions client (github-copilot OFF, standing /// in for groq) carries NONE of the Copilot editor headers and keeps the /// kigi User-Agent — every other ChatCompletions provider stays untouched. @@ -2181,6 +2249,88 @@ mod tests { ); } + // A JWT whose payload carries `["https://api.openai.com/auth"] + // ["chatgpt_account_id"] = "acct-test-42"` (alg=none; signature is cosmetic + // — the extractor only base64url-decodes the payload segment). + const CODEX_TEST_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC10ZXN0LTQyIn0sInN1YiI6InVzZXItMSJ9.sig"; + + /// openai-codex Responses client (`openai_codex = true`): the STATIC codex + /// identity headers (`originator`, `OpenAI-Beta`, codex `User-Agent`) ride + /// `default_headers`, and a built `post()` request derives `chatgpt-account- + /// id` from the bearer JWT. The Responses body default keeps `store: false`. + #[test] + fn openai_codex_client_sends_codex_identity_headers() { + let mut config = minimal_config(); + config.api_backend = ApiBackend::Responses; + config.openai_codex = true; + config.api_key = Some(CODEX_TEST_JWT.to_string()); + let client = SamplingClient::new(config).expect("client builds"); + let h = &client.default_headers; + assert_eq!( + h.get("originator").and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::CODEX_ORIGINATOR), + "codex must send originator: codex_cli_rs" + ); + assert_eq!( + h.get("openai-beta").and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::CODEX_OPENAI_BETA), + "codex must opt into OpenAI-Beta: responses=experimental" + ); + assert_eq!( + h.get(USER_AGENT).and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::CODEX_USER_AGENT), + "codex presents the codex User-Agent" + ); + // The account id is derived PER REQUEST from the bearer JWT in post(). + let req = client + .post("https://chatgpt.com/backend-api/codex/responses") + .build() + .expect("build request"); + assert_eq!( + req.headers() + .get("chatgpt-account-id") + .and_then(|v| v.to_str().ok()), + Some("acct-test-42"), + "chatgpt-account-id must be decoded from the bearer JWT claim" + ); + } + + /// REGRESSION: an API-key `openai` Responses client (`openai_codex = false`) + /// carries NONE of the Codex identity headers — not the static ones and not + /// the per-request `chatgpt-account-id` — so its request stays byte-identical. + #[test] + fn api_key_openai_responses_client_has_no_codex_headers() { + let mut config = minimal_config(); + config.api_backend = ApiBackend::Responses; + // openai_codex stays false (as it is for API-key openai). Even with a + // JWT-shaped key, no account-id header is derived. + config.api_key = Some(CODEX_TEST_JWT.to_string()); + let client = SamplingClient::new(config).expect("client builds"); + let h = &client.default_headers; + assert!( + h.get("originator").is_none(), + "openai must NOT send originator" + ); + assert!( + h.get("openai-beta").is_none(), + "openai must NOT send the codex OpenAI-Beta" + ); + assert!( + h.get(USER_AGENT) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ua| ua.starts_with("kigi/")), + "API-key openai keeps the kigi User-Agent" + ); + let req = client + .post("https://api.openai.com/v1/responses") + .build() + .expect("build request"); + assert!( + req.headers().get("chatgpt-account-id").is_none(), + "API-key openai must NOT send chatgpt-account-id (regression)" + ); + } + /// The system-prompt prefix is prepended as a distinct leading `text` /// block for each `system` shape (absent / string / blocks), preserving the /// caller's prompt, and is idempotent (not stamped twice). diff --git a/crates/codegen/kigi-sampler/src/config.rs b/crates/codegen/kigi-sampler/src/config.rs index e1d26eb..0dfc31a 100644 --- a/crates/codegen/kigi-sampler/src/config.rs +++ b/crates/codegen/kigi-sampler/src/config.rs @@ -70,6 +70,14 @@ pub struct SamplerConfig { /// byte-identical. #[serde(default)] pub github_copilot: bool, + /// ChatGPT/Codex Responses adaptation (openai-codex only). When true the + /// `/codex/responses` request carries the Codex identity headers + /// (`chatgpt-account-id` derived per-request from the bearer JWT, + /// `originator: codex_cli_rs`, `OpenAI-Beta: responses=experimental`, a codex + /// `User-Agent`). Gated so the API-key `openai` Responses requests stay + /// byte-identical (`store: false` is already the shared Responses default). + #[serde(default)] + pub openai_codex: bool, /// Extra request headers applied verbatim. The sampler never inspects /// the URL to derive headers; callers (the session) inject proxy auth /// and other access headers here before constructing the config. @@ -161,6 +169,7 @@ impl Default for SamplerConfig { auth_scheme: AuthScheme::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, extra_headers: IndexMap::new(), context_window: 0, force_http1: false, diff --git a/crates/codegen/kigi-sampler/tests/test_actor.rs b/crates/codegen/kigi-sampler/tests/test_actor.rs index 4bde302..1d0bb6f 100644 --- a/crates/codegen/kigi-sampler/tests/test_actor.rs +++ b/crates/codegen/kigi-sampler/tests/test_actor.rs @@ -80,6 +80,7 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig { auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 128_000, diff --git a/crates/codegen/kigi-sampling-types/Cargo.toml b/crates/codegen/kigi-sampling-types/Cargo.toml index 665f6e8..316b5dd 100644 --- a/crates/codegen/kigi-sampling-types/Cargo.toml +++ b/crates/codegen/kigi-sampling-types/Cargo.toml @@ -7,6 +7,7 @@ description = "Pure data types for the xAI sampling / chat-completion API layer" [dependencies] async-openai = { workspace = true } +base64 = { workspace = true } indexmap = { workspace = true, features = ["serde"] } reqwest = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index 7f2ca2c..90b041a 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -913,6 +913,11 @@ pub enum ReasoningEffort { /// Messages both accept `xhigh` AND `max` as separate levels in 2026; /// the Kimi wire spells its top tier `max` with no `xhigh`). Max, + /// Codex-only top tier above `max` (the ChatGPT Codex backend exposes an + /// `ultra` reasoning effort on its flagship models). Reachable ONLY via a + /// model's server-declared effort menu (openai-codex); no built-in fallback + /// menu offers it, so other providers never emit it. + Ultra, } impl ReasoningEffort { @@ -941,12 +946,15 @@ impl ReasoningEffort { Self::High => "high", Self::Xhigh => "xhigh", Self::Max => "max", + Self::Ultra => "ultra", } } /// Anthropic Messages API effort string; `None` for unsupported variants. /// `xhigh` and `max` are distinct levels on the 2026 Messages API (both - /// appear in `GET /v1/models` `capabilities.effort`). + /// appear in `GET /v1/models` `capabilities.effort`). `ultra` is codex-only + /// and never selected on an Anthropic model, but maps to its own string for + /// completeness (the Responses path writes effort via `as_str`, not this). pub fn to_messages_api(self) -> Option<&'static str> { match self { Self::None | Self::Minimal => None, @@ -955,6 +963,7 @@ impl ReasoningEffort { Self::High => Some("high"), Self::Xhigh => Some("xhigh"), Self::Max => Some("max"), + Self::Ultra => Some("ultra"), } } } @@ -977,8 +986,9 @@ impl std::str::FromStr for ReasoningEffort { "high" => Ok(Self::High), "xhigh" => Ok(Self::Xhigh), "max" => Ok(Self::Max), + "ultra" => Ok(Self::Ultra), _ => Err(format!( - "invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max)" + "invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max, ultra)" )), } } @@ -1098,6 +1108,59 @@ pub const COPILOT_API_VERSION: &str = "2026-06-01"; /// `X-Initiator` value — sent ONLY on inference (`user`, per the spec). pub const COPILOT_INITIATOR: &str = "user"; +// ── ChatGPT/Codex (openai-codex) OAuth-inference headers ───────────────────── +// The ChatGPT Codex backend authorizes an OAuth bearer AND validates the Codex +// client identity. These ride the `/codex/responses` inference request ONLY. +// openai-codex-GATED: no other Responses provider (API-key `openai`) sends them, +// so their requests stay byte-identical. Values are non-secret wire constants +// (ported from the official Codex CLI + Pi `api/openai-codex-responses.ts`). + +/// `originator` header identifying the Codex CLI client (matches the authorize +/// `originator` param). +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"; +/// `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"; +/// JWT payload claim namespace carrying the ChatGPT account id. +const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth"; + +/// Extract the `chatgpt_account_id` from a Codex OAuth access token (a JWT): +/// base64url-decode the payload segment and read +/// `["https://api.openai.com/auth"]["chatgpt_account_id"]`. Returns `None` when +/// the token is not a well-formed JWT or the claim is missing/empty. +/// +/// Used BOTH at login (fail-fast: a token without the claim is useless) and at +/// inference (the header is derived STATELESSLY from the current bearer, so a +/// refreshed token — which still carries the claim — needs no persisted field). +/// +/// SECURITY: the token, its payload, and the returned account id are NEVER +/// logged by this function or its callers. +pub fn chatgpt_account_id_from_jwt(token: &str) -> Option { + use base64::Engine; + // A JWT is exactly three dot-separated segments; anything else is not a + // token we can read (fail closed rather than decode a lookalike). + let mut segments = token.split('.'); + let (_header, payload_b64, _signature) = (segments.next()?, segments.next()?, segments.next()?); + if segments.next().is_some() { + return None; + } + // JWT payloads are base64url without padding; be tolerant of either. + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let account_id = claims + .get(CODEX_JWT_AUTH_CLAIM)? + .get("chatgpt_account_id")? + .as_str()?; + (!account_id.is_empty()).then(|| account_id.to_string()) +} + /// ChatCompletions request-body adaptation dialect. Providers disagree on /// how thinking rides an OpenAI-compatible body: Kimi wants /// `thinking:{type,effort}`, DeepSeek wants @@ -1633,6 +1696,86 @@ mod tests { assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max")); } + /// The codex-only `ultra` tier parses, serializes, and patches onto a + /// Responses body as `reasoning.effort = "ultra"` (the crux of surfacing a + /// codex model's full thinking menu). It is a DISTINCT level above `max`. + #[test] + fn reasoning_effort_ultra_is_a_distinct_codex_tier() { + assert_eq!( + "ultra".parse::().unwrap(), + ReasoningEffort::Ultra + ); + assert_eq!( + "ULTRA".parse::().unwrap(), + ReasoningEffort::Ultra + ); + assert_ne!(ReasoningEffort::Ultra, ReasoningEffort::Max); + assert_eq!(ReasoningEffort::Ultra.as_str(), "ultra"); + let json = serde_json::to_string(&ReasoningEffort::Ultra).unwrap(); + assert_eq!(json, "\"ultra\""); + assert_eq!( + serde_json::from_str::("\"ultra\"").unwrap(), + ReasoningEffort::Ultra + ); + let mut body = serde_json::json!({ "model": "gpt-5.6-sol" }); + patch_reasoning_effort(&mut body, Some(ReasoningEffort::Ultra)); + assert_eq!(body["reasoning"]["effort"], "ultra"); + } + + /// The account id is decoded STATELESSLY from the bearer JWT payload's + /// `["https://api.openai.com/auth"]["chatgpt_account_id"]` claim; a token + /// without the claim (or not a JWT) yields `None` (login fails fast on it). + #[test] + fn chatgpt_account_id_extracted_from_jwt_claim() { + use base64::Engine; + let make_jwt = |payload: serde_json::Value| -> String { + let header = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#); + let body = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + format!("{header}.{body}.sig") + }; + let good = make_jwt(serde_json::json!({ + "https://api.openai.com/auth": { "chatgpt_account_id": "acct-abc-123" }, + "sub": "user-1" + })); + assert_eq!( + chatgpt_account_id_from_jwt(&good).as_deref(), + Some("acct-abc-123") + ); + // Claim namespace present but no account id → None (fail-fast). + let no_account = make_jwt(serde_json::json!({ + "https://api.openai.com/auth": { "user_id": "u" } + })); + assert_eq!(chatgpt_account_id_from_jwt(&no_account), None); + // Empty account id → None. + let empty = make_jwt(serde_json::json!({ + "https://api.openai.com/auth": { "chatgpt_account_id": "" } + })); + assert_eq!(chatgpt_account_id_from_jwt(&empty), None); + // Not a JWT (no payload segment) → None. + assert_eq!(chatgpt_account_id_from_jwt("not-a-jwt"), None); + assert_eq!(chatgpt_account_id_from_jwt(""), None); + // A JWT is EXACTLY three segments: a lookalike with too few or too many + // is rejected outright rather than decoded (fail closed). + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({ + "https://api.openai.com/auth": { "chatgpt_account_id": "acct-abc-123" } + })) + .unwrap(), + ); + assert_eq!( + chatgpt_account_id_from_jwt(&format!("hdr.{payload}")), + None, + "two segments is not a JWT" + ); + assert_eq!( + chatgpt_account_id_from_jwt(&format!("hdr.{payload}.sig.extra")), + None, + "four segments is not a JWT" + ); + } + #[test] fn parse_canonical_effort_token_helper() { assert_eq!( @@ -1797,7 +1940,8 @@ mod tests { ); let bad_type = as_map(serde_json::json!({"reasoningEffort": 3})); assert_eq!(parse_reasoning_effort_meta(Some(&bad_type)), None); - let unknown = as_map(serde_json::json!({"reasoningEffort": "ULTRA"})); + // `ultra` is now a real codex tier; a genuinely-unknown token still None. + let unknown = as_map(serde_json::json!({"reasoningEffort": "MEGA"})); assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None); } diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index d750625..3b45dba 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -605,6 +605,11 @@ mod tests { ); assert_eq!( ids[kimi_pos + 4], + "openai-codex", + "openai-codex is the next interactive OAuth login, after github-copilot" + ); + assert_eq!( + ids[kimi_pos + 5], MOONSHOT_CN_METHOD_ID, "the api-key rows follow the generic oauth logins" ); @@ -756,6 +761,7 @@ mod tests { "xai-grok", "claude-pro-max", "github-copilot", + "openai-codex", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", @@ -807,6 +813,7 @@ mod tests { "xai-grok", "claude-pro-max", "github-copilot", + "openai-codex", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", @@ -851,6 +858,7 @@ mod tests { "xai-grok", "claude-pro-max", "github-copilot", + "openai-codex", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", @@ -898,6 +906,7 @@ mod tests { "xai-grok", "claude-pro-max", "github-copilot", + "openai-codex", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index cbba93f..c5683d9 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -4099,6 +4099,15 @@ pub fn sampling_config_for_model( .as_deref() .and_then(kigi_models::parse_managed_model_key) .is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()); + // ChatGPT/Codex Responses identity headers: a managed key whose platform is + // openai-codex drives the codex headers (`chatgpt-account-id` + originator + + // OpenAI-Beta) in the sampler. Gated here so API-key `openai` Responses + // requests stay byte-identical. + let openai_codex = info + .id + .as_deref() + .and_then(kigi_models::parse_managed_model_key) + .is_some_and(|(platform, _)| platform.sends_codex_responses_headers()); SamplerConfig { api_key: credentials.api_key, model: model_name, @@ -4110,6 +4119,7 @@ pub fn sampling_config_for_model( auth_scheme: credentials.auth_scheme, anthropic_oauth, github_copilot, + openai_codex, chat_compat, extra_headers, context_window: info.context_window.get(), diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index fe51d80..16861ee 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -334,6 +334,19 @@ 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), @@ -1401,6 +1414,104 @@ 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`/`ultra`). + /// 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_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, + "codex-session-tok".to_string(), + ); + let keys = crate::agent::models::PlatformApiKeys::default(); + let result = tokio::task::spawn_blocking(move || { + fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys) + }) + .await + .unwrap() + .expect("openai-codex hardcoded catalog fetch must succeed with no HTTP"); + + assert_eq!( + result + .models + .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/" + ); + // 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!( + sol.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!( + !sol.supported_in_api, + "subscription (uses_oauth) models require the OAuth session" + ); + assert!(sol.supports_reasoning_effort); + assert_eq!( + sol.reasoning_efforts + .iter() + .map(|o| o.id.as_str()) + .collect::>(), + vec!["low", "medium", "high", "xhigh", "max", "ultra"], + "sol exposes the full codex effort menu incl. ultra" + ); + // gpt-5.5 tops out at xhigh (no max/ultra). + let five_five = result + .models + .iter() + .find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.5")) + .unwrap(); + assert_eq!( + five_five + .reasoning_efforts + .iter() + .map(|o| o.id.as_str()) + .collect::>(), + vec!["low", "medium", "high", "xhigh"] + ); + } + /// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts /// (high/max) produce ChatCompletions entries whose sampler config /// speaks the DeepSeek thinking dialect. diff --git a/crates/codegen/kigi-shell/src/agent/session_config.rs b/crates/codegen/kigi-shell/src/agent/session_config.rs index 852974b..d54acee 100644 --- a/crates/codegen/kigi-shell/src/agent/session_config.rs +++ b/crates/codegen/kigi-shell/src/agent/session_config.rs @@ -60,6 +60,7 @@ fn effort_label(effort: ReasoningEffort) -> String { ReasoningEffort::High => "High", ReasoningEffort::Xhigh => "X-High", ReasoningEffort::Max => "Max", + ReasoningEffort::Ultra => "Ultra", } .to_string() } diff --git a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs index 3d74936..32929ba 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs @@ -893,6 +893,11 @@ async fn read_parent_sampling_config( // false, so the other ChatCompletions paths stay byte-identical. let github_copilot = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref()) .is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()); + // ChatGPT/Codex headers inherit from the parent model's platform + // (openai-codex → true); every other platform / BYOK → false, so the + // API-key openai Responses path stays byte-identical. + let openai_codex = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref()) + .is_some_and(|(platform, _)| platform.sends_codex_responses_headers()); let inherited = kigi_sampler::SamplerConfig { api_key: creds.api_key, base_url: cfg.base_url, @@ -904,6 +909,7 @@ async fn read_parent_sampling_config( auth_scheme, anthropic_oauth, github_copilot, + openai_codex, chat_compat: cfg.chat_compat, extra_headers, context_window: cfg.context_window.get(), diff --git a/crates/codegen/kigi-shell/src/auth/flow.rs b/crates/codegen/kigi-shell/src/auth/flow.rs index 598e42a..d31872a 100644 --- a/crates/codegen/kigi-shell/src/auth/flow.rs +++ b/crates/codegen/kigi-shell/src/auth/flow.rs @@ -104,8 +104,18 @@ pub async fn run_oauth_provider_flow( ) .await } - kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => { - run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await + kigi_models::OAuthFlow::PkceLocalhost { + redirect_port, + redirect_path, + } => { + run_pkce_localhost_login( + oauth, + redirect_port, + redirect_path, + auth_manager, + &mut channels, + ) + .await } kigi_models::OAuthFlow::GithubDeviceCopilot => { crate::auth::device_code::run_device_code_login_github_copilot( @@ -118,23 +128,40 @@ pub async fn run_oauth_provider_flow( } } -/// PKCE-localhost login (claude-pro-max): generate PKCE, present the browser -/// authorize URL (TUI channel or stderr), open the browser, then await the code -/// from EITHER the `127.0.0.1:{redirect_port}` loopback callback OR a manual -/// paste (headless fallback). Exchange it at the token endpoint and persist. +/// PKCE-localhost login (claude-pro-max JSON, openai-codex FORM): generate PKCE, +/// present the browser authorize URL (TUI channel or stderr), open the browser, +/// then await the code from EITHER the `127.0.0.1:{redirect_port}{redirect_path}` +/// loopback callback OR a manual paste (headless fallback). Exchange it at the +/// token endpoint and persist. /// -/// SECURITY: the verifier / code / tokens are never logged; the loopback binds -/// `127.0.0.1` only and validates `state` strictly. +/// The `token_body` selects the wire dialect: `Json` is the claude path +/// (`state == verifier`, JSON exchange carrying `state`); `Form` is the codex +/// path (fresh-random `state`, FORM exchange WITHOUT `state`, then a FAIL-FAST +/// check that the minted JWT carries a `chatgpt_account_id` — a token without it +/// is useless for inference, so the login bails rather than persisting it). +/// +/// SECURITY: the verifier / code / tokens / JWT / account id are never logged; +/// the loopback binds `127.0.0.1` only and validates `state` strictly. async fn run_pkce_localhost_login( oauth: &'static kigi_models::OAuthConfig, redirect_port: u16, + redirect_path: &'static str, auth_manager: &Arc, channels: &mut Option, ) -> anyhow::Result<(KimiAuth, bool)> { use crate::auth::oauth_pkce; - let pkce = oauth_pkce::generate_pkce(); - let redirect = oauth_pkce::redirect_uri(redirect_port); + // The token-body encoding also selects the PKCE `state` convention: the JSON + // dialect is Claude's/Pi's `state == verifier`, while form-encoded endpoints + // (the OAuth norm) get an INDEPENDENT random state. A new form provider + // inheriting the standard random state is correct by default. + let uses_form_exchange = matches!(oauth.token_body, kigi_models::OAuthTokenBody::Form); + let pkce = if uses_form_exchange { + oauth_pkce::generate_pkce_random_state() + } else { + oauth_pkce::generate_pkce() + }; + let redirect = oauth_pkce::redirect_uri(redirect_port, redirect_path); let authorize_url = oauth_pkce::build_authorize_url(oauth, &redirect, &pkce); let mut chans = channels.take(); @@ -148,7 +175,7 @@ async fn run_pkce_localhost_login( crate::auth::device_code::open_browser_detached(&authorize_url).await; } else { eprintln!(); - eprintln!("To sign in to Claude Pro/Max, open this URL in your browser:"); + eprintln!("To sign in, open this URL in your browser:"); eprintln!(); eprintln!(" {authorize_url}"); eprintln!(); @@ -159,8 +186,24 @@ async fn run_pkce_localhost_login( eprintln!("Waiting for the sign-in to complete..."); } - let code = await_pkce_code(redirect_port, &pkce, chans.as_mut()).await?; - let auth = oauth_pkce::exchange_code(oauth, &code, &pkce, &redirect).await?; + let code = await_pkce_code(redirect_port, redirect_path, &pkce, chans.as_mut()).await?; + let auth = if uses_form_exchange { + oauth_pkce::exchange_code_form(oauth, &code, &pkce, &redirect).await? + } else { + oauth_pkce::exchange_code(oauth, &code, &pkce, &redirect).await? + }; + // FAIL FAST: an access token that yields no chatgpt_account_id cannot + // authorize inference (it becomes the `chatgpt-account-id` header) — bail + // rather than persist a dead session. Gated on the EXPLICIT provider fact, + // never on the token-body encoding. + if oauth.requires_chatgpt_account_id + && kigi_sampling_types::chatgpt_account_id_from_jwt(&auth.key).is_none() + { + anyhow::bail!( + "ChatGPT login did not return a usable account id \ + (the access token is missing the chatgpt_account_id claim)" + ); + } let auth = auth_manager .update(auth) .await @@ -174,6 +217,7 @@ async fn run_pkce_localhost_login( /// arms (strict on the loopback, mismatch-rejecting on the paste). async fn await_pkce_code( redirect_port: u16, + redirect_path: &str, pkce: &crate::auth::oauth_pkce::PkceCodes, channels: Option<&mut AuthChannels>, ) -> anyhow::Result { @@ -181,7 +225,7 @@ async fn await_pkce_code( match channels { Some(ch) => { tokio::select! { - code = oauth_pkce::await_loopback_code(redirect_port, &pkce.state) => code, + code = oauth_pkce::await_loopback_code(redirect_port, redirect_path, &pkce.state) => code, pasted = ch.code_rx.recv() => { let pasted = pasted .ok_or_else(|| anyhow::anyhow!("auth code channel closed before a code arrived"))?; @@ -191,7 +235,7 @@ async fn await_pkce_code( } } } - None => oauth_pkce::await_loopback_code(redirect_port, &pkce.state).await, + None => oauth_pkce::await_loopback_code(redirect_port, redirect_path, &pkce.state).await, } } diff --git a/crates/codegen/kigi-shell/src/auth/oauth_pkce.rs b/crates/codegen/kigi-shell/src/auth/oauth_pkce.rs index 880a6e9..2ce33f1 100644 --- a/crates/codegen/kigi-shell/src/auth/oauth_pkce.rs +++ b/crates/codegen/kigi-shell/src/auth/oauth_pkce.rs @@ -1,18 +1,24 @@ //! Generic authorization-code + PKCE (S256) OAuth wire with a `127.0.0.1` //! loopback callback, driven by a registry [`kigi_models::OAuthConfig`] whose -//! `flow` is [`OAuthFlow::PkceLocalhost`] (claude-pro-max today). +//! `flow` is [`OAuthFlow::PkceLocalhost`] (claude-pro-max JSON + openai-codex +//! FORM). //! -//! Shape (Pi `earendil-works/pi` `auth/oauth/anthropic.ts`): +//! Shape (Pi `earendil-works/pi` `auth/oauth/{anthropic,openai-codex}.ts`): //! - `verifier = base64url(32 random bytes)`; `challenge = base64url(SHA-256( -//! verifier))`; `state = verifier`. +//! verifier))`. `state` is `verifier` for claude ([`generate_pkce`]) or a +//! fresh-random value for codex ([`generate_pkce_random_state`]). //! - Browser opens `{auth_host}{device_path}?client_id&response_type=code& -//! scope&redirect_uri&state&code_challenge&code_challenge_method=S256`. +//! scope&redirect_uri&state&code_challenge&code_challenge_method=S256` plus any +//! `authorize_extra` params (codex only). //! - The code returns to a loopback listener on `127.0.0.1:{redirect_port}` -//! answering ONLY `/callback`, with STRICT `state` validation (a mismatch is -//! rejected — CSRF guard). A manual paste (redirect URL / `code#state` / bare -//! code) is accepted as a headless fallback. -//! - Code → token exchange and refresh POST `{token_host}{token_path}` as JSON -//! (per `token_body`); the refresh token ROTATES. +//! answering ONLY `{redirect_path}` (claude `/callback`, codex +//! `/auth/callback`), with STRICT `state` validation (a mismatch is rejected — +//! CSRF guard). A manual paste (redirect URL / `code#state` / bare code) is +//! accepted as a headless fallback. +//! - Code → token exchange POSTs `{token_host}{token_path}` as JSON +//! ([`exchange_code`], claude) or FORM ([`exchange_code_form`], codex; NO +//! `state` field). Refresh: claude JSON here ([`refresh_token`], rotating); +//! codex takes the generic device refresher's FORM path. //! //! SECURITY: the verifier, authorization code, access token, and refresh token //! are NEVER logged (only non-secret events: authorize URL requested, callback @@ -34,16 +40,19 @@ const MAX_REFRESH_RETRIES: u32 = 3; /// HTTP statuses worth retrying a refresh for (parity with the device wire). const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504]; -/// PKCE secrets for one login attempt. `state == verifier` (Pi's convention: -/// the state is the verifier, so a returned state binds the callback to this -/// attempt AND doubles as the CSRF token). +/// PKCE secrets for one login attempt. Two `state` conventions ship: +/// [`generate_pkce`] sets `state == verifier` (Pi's/Claude's convention), while +/// [`generate_pkce_random_state`] mints an INDEPENDENT random state (the OAuth +/// standard, used by ChatGPT/Codex). Either way the state is validated on the +/// callback as the CSRF guard. #[derive(Debug, Clone)] pub(crate) struct PkceCodes { /// `code_verifier` — the 43-char base64url secret, sent at token exchange. pub verifier: String, /// `code_challenge = base64url(SHA-256(verifier))`, sent at authorize. pub challenge: String, - /// `state` — equal to `verifier`; validated on the callback (CSRF guard). + /// `state` — the verifier itself, or an independent random value depending + /// on the provider's dialect; validated on the callback (CSRF guard). pub state: String, } @@ -63,30 +72,51 @@ pub(crate) fn generate_pkce() -> PkceCodes { } } -/// The loopback redirect URI for a PKCE-localhost provider. -pub(crate) fn redirect_uri(redirect_port: u16) -> String { - format!("http://localhost:{redirect_port}/callback") +/// Like [`generate_pkce`] but with an INDEPENDENT fresh-random `state` (16 +/// random bytes) instead of `state == verifier`. The ChatGPT/Codex flow uses a +/// distinct state (the verifier never doubles as the CSRF token there), so the +/// verifier stays out of the state carried on the loopback callback. +pub(crate) fn generate_pkce_random_state() -> PkceCodes { + use rand::RngCore; + let mut raw = [0u8; 16]; + rand::rng().fill_bytes(&mut raw); + let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw); + PkceCodes { + state, + ..generate_pkce() + } +} + +/// The loopback redirect URI for a PKCE-localhost provider (claude `/callback`, +/// codex `/auth/callback`). +pub(crate) fn redirect_uri(redirect_port: u16, redirect_path: &str) -> String { + format!("http://localhost:{redirect_port}{redirect_path}") } /// Build the browser authorize URL: /// `{auth_host}{device_path}?client_id&response_type=code&scope&redirect_uri& -/// state&code_challenge&code_challenge_method=S256`. +/// state&code_challenge&code_challenge_method=S256` plus any config +/// `authorize_extra` params (empty for every config but codex, so their URLs +/// stay byte-identical). pub(crate) fn build_authorize_url( cfg: &OAuthConfig, redirect_uri: &str, pkce: &PkceCodes, ) -> String { let base = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.device_path); - let query = url::form_urlencoded::Serializer::new(String::new()) + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer .append_pair("client_id", cfg.client_id) .append_pair("response_type", "code") .append_pair("scope", cfg.scope) .append_pair("redirect_uri", redirect_uri) .append_pair("state", &pkce.state) .append_pair("code_challenge", &pkce.challenge) - .append_pair("code_challenge_method", "S256") - .finish(); - format!("{base}?{query}") + .append_pair("code_challenge_method", "S256"); + for (key, value) in cfg.authorize_extra { + serializer.append_pair(key, value); + } + format!("{base}?{}", serializer.finish()) } /// `code` + `state` extracted from a callback (loopback query OR manual paste). @@ -240,6 +270,52 @@ pub(crate) async fn exchange_code( Ok(tokens.into_auth()) } +/// Exchange an authorization `code` for a token set with a FORM body (codex): +/// `{grant_type=authorization_code, client_id, code, code_verifier, +/// redirect_uri}`. Unlike [`exchange_code`], the `state` is NOT sent in the +/// token body (the ChatGPT/Codex token endpoint does not expect it). Asserts a +/// `Form` config so a JSON provider can never silently mis-encode. +pub(crate) async fn exchange_code_form( + cfg: &OAuthConfig, + code: &str, + pkce: &PkceCodes, + redirect_uri: &str, +) -> anyhow::Result { + debug_assert!( + matches!(cfg.token_body, OAuthTokenBody::Form), + "PKCE form exchange expects a Form token body" + ); + tracing::info!( + scope_key = cfg.scope_key, + "auth: exchanging code for token (pkce form)" + ); + let resp = crate::http::shared_client() + .post(token_url(cfg)) + .header("Accept", "application/json") + .form(&[ + ("grant_type", CODE_GRANT_TYPE), + ("client_id", cfg.client_id), + ("code", code), + ("code_verifier", pkce.verifier.as_str()), + ("redirect_uri", redirect_uri), + ]) + .send() + .await + .context("token exchange request failed")?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + tracing::warn!(%status, scope_key = cfg.scope_key, "auth: code exchange failed (pkce form)"); + anyhow::bail!("Token exchange failed (HTTP {status}): {body}"); + } + let tokens: TokenResponse = resp.json().await.context("malformed token payload")?; + tracing::info!( + scope_key = cfg.scope_key, + "auth: pkce form code exchange succeeded" + ); + Ok(tokens.into_auth()) +} + /// `POST {token_host}{token_path}` with `grant_type=refresh_token` (JSON body). /// Claude ROTATES the refresh token, so the caller MUST persist the returned /// one. Retries the retryable statuses / network errors with exponential @@ -320,13 +396,16 @@ struct OAuthErrorBody { } /// Bind a loopback HTTP listener on `127.0.0.1:{redirect_port}` and wait for a -/// single `GET /callback?code=…&state=…`, validating `state` STRICTLY against -/// `expected_state` (mismatch → rejected). Returns the authorization code. +/// single `GET {redirect_path}?code=…&state=…`, validating `state` STRICTLY +/// against `expected_state` (mismatch → rejected). Returns the authorization +/// code. /// -/// The listener answers ONLY `/callback`; any other path gets 404. It binds -/// `127.0.0.1` (never `0.0.0.0`), so no non-loopback host can reach it. +/// The listener answers ONLY `redirect_path` (claude `/callback`, codex +/// `/auth/callback`); any other path gets 404. It binds `127.0.0.1` (never +/// `0.0.0.0`), so no non-loopback host can reach it. pub(crate) async fn await_loopback_code( redirect_port: u16, + redirect_path: &str, expected_state: &str, ) -> anyhow::Result { let listener = tokio::net::TcpListener::bind(("127.0.0.1", redirect_port)) @@ -335,10 +414,10 @@ pub(crate) async fn await_loopback_code( tracing::info!(port = redirect_port, "auth: pkce loopback listener bound"); loop { let (stream, _peer) = listener.accept().await.context("loopback accept failed")?; - match handle_loopback_conn(stream, expected_state).await { + match handle_loopback_conn(stream, redirect_path, expected_state).await { LoopbackOutcome::Code(code) => return Ok(code), LoopbackOutcome::Rejected(err) => return Err(err), - // Not the /callback GET (favicon, health probe): keep listening. + // Not the callback GET (favicon, health probe): keep listening. LoopbackOutcome::Ignore => continue, } } @@ -355,6 +434,7 @@ enum LoopbackOutcome { /// state is [`LoopbackOutcome::Rejected`] (the browser sees an error page). async fn handle_loopback_conn( mut stream: tokio::net::TcpStream, + redirect_path: &str, expected_state: &str, ) -> LoopbackOutcome { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -370,7 +450,7 @@ async fn handle_loopback_conn( let Some(request_line) = head.lines().next() else { return LoopbackOutcome::Ignore; }; - // `GET /callback?code=…&state=… HTTP/1.1` + // `GET {redirect_path}?code=…&state=… HTTP/1.1` let mut parts = request_line.split_whitespace(); let (Some(method), Some(target)) = (parts.next(), parts.next()) else { return LoopbackOutcome::Ignore; @@ -380,7 +460,7 @@ async fn handle_loopback_conn( return LoopbackOutcome::Ignore; } let (path, query) = target.split_once('?').unwrap_or((target, "")); - if path != "/callback" { + if path != redirect_path { let _ = write_http(&mut stream, 404, "Not Found").await; return LoopbackOutcome::Ignore; } @@ -392,7 +472,7 @@ async fn handle_loopback_conn( let _ = write_http( &mut stream, 200, - "Signed in to Claude Pro/Max. You can close this window and return to kigi.", + "Signed in. You can close this window and return to kigi.", ) .await; let _ = stream.flush().await; @@ -473,7 +553,7 @@ mod tests { #[test] fn authorize_url_has_state_and_s256_challenge() { let pkce = generate_pkce(); - let redirect = redirect_uri(53692); + let redirect = redirect_uri(53692, "/callback"); let url = build_authorize_url(&CLAUDE_OAUTH_CONFIG, &redirect, &pkce); let parsed = url::Url::parse(&url).expect("valid URL"); assert_eq!(parsed.host_str(), Some("claude.ai")); @@ -545,7 +625,10 @@ mod tests { let port = probe.local_addr().unwrap().port(); drop(probe); - let server = tokio::spawn(async move { await_loopback_code(port, "the-real-state").await }); + let server = + tokio::spawn( + async move { await_loopback_code(port, "/callback", "the-real-state").await }, + ); // Give the listener a moment to bind. tokio::time::sleep(std::time::Duration::from_millis(50)).await; // Attacker callback: valid code, WRONG state. @@ -567,7 +650,8 @@ mod tests { let port = probe.local_addr().unwrap().port(); drop(probe); - let server = tokio::spawn(async move { await_loopback_code(port, "good-state").await }); + let server = + tokio::spawn(async move { await_loopback_code(port, "/callback", "good-state").await }); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let _ = reqwest::get(format!( "http://127.0.0.1:{port}/callback?code=auth-code-123&state=good-state" @@ -622,9 +706,14 @@ mod tests { .await; let cfg = mock_cfg(host); let pkce = generate_pkce(); - let auth = exchange_code(&cfg, "auth-code-xyz", &pkce, &redirect_uri(53692)) - .await - .unwrap(); + let auth = exchange_code( + &cfg, + "auth-code-xyz", + &pkce, + &redirect_uri(53692, "/callback"), + ) + .await + .unwrap(); assert_eq!(auth.key, "sk-ant-oat-new"); assert_eq!(auth.refresh_token.as_deref(), Some("sk-ant-ort-new")); assert_eq!(auth.expires_in, Some(3600)); @@ -684,4 +773,132 @@ mod tests { other => panic!("expected Unauthorized, got {other:?}"), } } + + // ── ChatGPT/Codex PKCE (openai-codex) ──────────────────────────────────── + + /// Codex PKCE uses an INDEPENDENT fresh-random state (NOT `state == + /// verifier`) so the verifier never rides the callback. + #[test] + fn codex_pkce_state_is_independent_of_the_verifier() { + let pkce = generate_pkce_random_state(); + assert_ne!( + pkce.state, pkce.verifier, + "codex state must be fresh-random, not the verifier" + ); + assert!(!pkce.state.is_empty() && !pkce.verifier.is_empty()); + // Challenge is still the S256 of the verifier. + let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(Sha256::digest(pkce.verifier.as_bytes())); + assert_eq!(pkce.challenge, expect); + assert_ne!( + generate_pkce_random_state().state, + pkce.state, + "fresh state" + ); + } + + /// The codex authorize URL carries the PKCE state + S256 challenge AND the + /// three codex-only extra params, and targets `auth.openai.com/oauth/ + /// authorize` with the `/auth/callback` redirect. The verifier never rides it. + #[test] + fn codex_authorize_url_has_state_challenge_and_three_extra_params() { + use kigi_models::CODEX_OAUTH_CONFIG; + let pkce = generate_pkce_random_state(); + let redirect = redirect_uri(1455, "/auth/callback"); + assert_eq!(redirect, "http://localhost:1455/auth/callback"); + let url = build_authorize_url(&CODEX_OAUTH_CONFIG, &redirect, &pkce); + let parsed = url::Url::parse(&url).expect("valid URL"); + assert_eq!(parsed.host_str(), Some("auth.openai.com")); + assert_eq!(parsed.path(), "/oauth/authorize"); + let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect(); + assert_eq!( + q.get("state").map(String::as_str), + Some(pkce.state.as_str()) + ); + assert_eq!( + q.get("code_challenge").map(String::as_str), + Some(pkce.challenge.as_str()) + ); + assert_eq!( + q.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + // The three codex-only extra params. + assert_eq!( + q.get("id_token_add_organizations").map(String::as_str), + Some("true") + ); + assert_eq!( + q.get("codex_cli_simplified_flow").map(String::as_str), + Some("true") + ); + assert_eq!( + q.get("originator").map(String::as_str), + Some("codex_cli_rs") + ); + assert!( + !url.contains("code_verifier"), + "the verifier must not ride the authorize URL" + ); + } + + /// The claude authorize URL is UNCHANGED (no extra params) — its empty + /// `authorize_extra` keeps it byte-identical. + #[test] + fn claude_authorize_url_carries_no_extra_params() { + let pkce = generate_pkce(); + let url = build_authorize_url( + &CLAUDE_OAUTH_CONFIG, + &redirect_uri(53692, "/callback"), + &pkce, + ); + assert!(!url.contains("id_token_add_organizations")); + assert!(!url.contains("codex_cli_simplified_flow")); + assert!(!url.contains("originator")); + } + + fn codex_mock_cfg(token_host: &'static str) -> OAuthConfig { + OAuthConfig { + token_host, + ..kigi_models::CODEX_OAUTH_CONFIG + } + } + + /// Codex code→token exchange posts a FORM body carrying the grant + code + + /// verifier + redirect_uri, and NOTABLY NO `state` field (the codex token + /// endpoint does not expect it). Response materializes a `KimiAuth`. + #[tokio::test] + async fn codex_exchange_code_posts_form_without_state() { + use wiremock::matchers::{body_string_contains, header, method, path}; + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(header("content-type", "application/x-www-form-urlencoded")) + .and(body_string_contains("grant_type=authorization_code")) + .and(body_string_contains("code=codex-auth-code")) + .and(body_string_contains("code_verifier=")) + .and(body_string_contains("redirect_uri=")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "codex-access-jwt", + "refresh_token": "codex-refresh", + "expires_in": 3600, + }))) + .expect(1) + .mount(&server) + .await; + let cfg = codex_mock_cfg(host); + let pkce = generate_pkce_random_state(); + let auth = exchange_code_form( + &cfg, + "codex-auth-code", + &pkce, + "http://localhost:1455/auth/callback", + ) + .await + .unwrap(); + assert_eq!(auth.key, "codex-access-jwt"); + assert_eq!(auth.refresh_token.as_deref(), Some("codex-refresh")); + assert_eq!(auth.expires_in, Some(3600)); + } } diff --git a/crates/codegen/kigi-shell/src/auth/oauth_registry.rs b/crates/codegen/kigi-shell/src/auth/oauth_registry.rs index 7f45406..0edaa05 100644 --- a/crates/codegen/kigi-shell/src/auth/oauth_registry.rs +++ b/crates/codegen/kigi-shell/src/auth/oauth_registry.rs @@ -153,6 +153,46 @@ mod tests { .expect("github-copilot carries an OAuthConfig") } + fn codex_oauth() -> &'static kigi_models::OAuthConfig { + kigi_models::PlatformId::OpenaiCodex + .oauth() + .expect("openai-codex carries an OAuthConfig") + } + + /// An `openai-codex/` turn resolves to the process-global pooled + /// openai-codex manager (its OWN `oauth/openai-codex` scope), NEVER the + /// primary Kimi manager — the same leak-safe routing as the other OAuth + /// platforms, and a DISTINCT pool entry from each. Fail-fast: even with a + /// Kimi primary, a codex turn never yields the Kimi bearer. + #[tokio::test] + async fn openai_codex_model_resolves_to_its_own_manager_not_kimi() { + let (_kd, kimi) = primary_with_token("kimi-tok"); + let home = tempfile::tempdir().unwrap(); + let resolved = manager_for_model(home.path(), "openai-codex/gpt-5.5", Some(&kimi)) + .expect("openai-codex model resolves to its pooled manager"); + assert!( + !Arc::ptr_eq(&resolved, &kimi), + "openai-codex must NOT resolve to the Kimi manager" + ); + assert!( + Arc::ptr_eq(&resolved, &global_manager_for(home.path(), codex_oauth())), + "openai-codex must resolve to its OWN process-global pooled manager" + ); + assert!( + !Arc::ptr_eq(&resolved, &global_manager_for(home.path(), copilot_oauth())), + "openai-codex and github-copilot must not share a pooled manager" + ); + assert!( + !Arc::ptr_eq(&resolved, &global_manager_for(home.path(), claude_oauth())), + "openai-codex and claude-pro-max must not share a pooled manager" + ); + assert_ne!( + session_key_for_model(home.path(), "openai-codex/gpt-5.5", Some(&kimi)), + Some("kimi-tok".to_string()), + "an openai-codex model must never receive the primary Kimi token" + ); + } + /// A `github-copilot/` turn resolves to the process-global pooled /// github-copilot manager (its OWN `oauth/github-copilot` scope), NEVER the /// primary Kimi manager — the same leak-safe routing as xai-grok / diff --git a/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs b/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs index b68ee2c..2c7be5d 100644 --- a/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs +++ b/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs @@ -1,6 +1,9 @@ -//! Generic device-code token refresher: drives `POST {token_path}` with -//! `grant_type=refresh_token` for any [`kigi_models::OAuthConfig`] provider -//! (xai-grok today) through the [`TokenRefresher`] seam. +//! Generic OAuth token refresher for any [`kigi_models::OAuthConfig`] provider, +//! driven through the [`TokenRefresher`] seam. The wire call is selected by the +//! config's `token_body`: form-encoded `grant_type=refresh_token` (xai-grok, +//! openai-codex), JSON (claude-pro-max), or the GitHub Copilot copilot-token +//! RE-MINT. Providers with `requires_chatgpt_account_id` additionally fail fast +//! when the refreshed token drops the claim. //! //! Structurally identical to [`super::kimi_refresher::KimiRefresher`] — same //! sibling-adoption + post-401 grace — but the wire call goes through @@ -118,6 +121,22 @@ impl TokenRefresher for GenericDeviceRefresher { } }; match wire_result { + Ok(new_auth) + if self.cfg.requires_chatgpt_account_id + && kigi_sampling_types::chatgpt_account_id_from_jwt(&new_auth.key) + .is_none() => + { + // FAIL FAST (never silently): the refreshed token carries no + // `chatgpt_account_id`, so every inference request would go out + // WITHOUT the required `chatgpt-account-id` header and draw an + // opaque backend 4xx. Surface it as a permanent failure so the + // user is told to re-login. + tracing::warn!( + scope_key = self.cfg.scope_key, + "auth: refreshed token is missing the chatgpt_account_id claim" + ); + RefreshOutcome::permanent(RefreshTokenFailedReason::Other, Some(refresh_token)) + } Ok(new_auth) => { kigi_log::unified_log::info( "auth.refresh.token_rotated", diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs index 1f493c7..1d67e33 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs @@ -283,6 +283,16 @@ impl SessionActor { kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model)) .is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()) } + /// Whether `model` routes to the ChatGPT/Codex Responses platform + /// (openai-codex) — the gate for the sampler's Codex identity headers + /// (`chatgpt-account-id` + originator + OpenAI-Beta). Every other model + /// returns `false`, keeping the API-key `openai` Responses request + /// byte-identical. + fn model_is_openai_codex(&self, model: &str) -> bool { + let managed_key = self.managed_key_for_model(model); + kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model)) + .is_some_and(|(platform, _)| platform.sends_codex_responses_headers()) + } /// LEAK guard for the stamped aux paths (auto-mode classifier, image /// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`] /// has copied the SESSION model's `bearer_resolver` onto an aux @@ -394,6 +404,8 @@ impl SessionActor { let anthropic_oauth = self.model_is_anthropic_oauth(&cfg.model); // GitHub Copilot editor-identity headers for THIS turn's model. let github_copilot = self.model_is_github_copilot(&cfg.model); + // ChatGPT/Codex identity headers for THIS turn's model. + let openai_codex = self.model_is_openai_codex(&cfg.model); let auth_scheme = model_facts.auth_scheme; let mut extra_headers = cfg.extra_headers; crate::agent::config::inject_url_derived_headers( @@ -436,6 +448,7 @@ impl SessionActor { auth_scheme, anthropic_oauth, github_copilot, + openai_codex, chat_compat: cfg.chat_compat, extra_headers, context_window: cfg.context_window.get(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs index 3c1fe07..6e974ab 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs @@ -851,6 +851,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() { auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, extra_headers: Default::default(), context_window: 256_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index f1011d1..4a72253 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -48,6 +48,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() { auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, extra_headers: Default::default(), context_window: 100_000, force_http1: false, @@ -346,6 +347,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() { auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, context_window: 100_000, force_http1: false, max_retries: None, @@ -478,6 +480,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, context_window: 100_000, force_http1: false, max_retries: None, @@ -1748,6 +1751,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, extra_headers: Default::default(), context_window: 100_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs b/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs index 8d5fe3c..b57e185 100644 --- a/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs +++ b/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs @@ -1592,6 +1592,7 @@ mod reasoning_compaction_regression_tests { auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, extra_headers: Default::default(), context_window: 256_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs index 1f6b4de..66f9a25 100644 --- a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs +++ b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs @@ -48,6 +48,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap) -> SubagentSpawnCon auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, extra_headers: Default::default(), context_window: 256_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/tests/common/mod.rs b/crates/codegen/kigi-shell/tests/common/mod.rs index da091b7..599f11f 100644 --- a/crates/codegen/kigi-shell/tests/common/mod.rs +++ b/crates/codegen/kigi-shell/tests/common/mod.rs @@ -40,6 +40,7 @@ pub fn test_sampler_config( auth_scheme: Default::default(), anthropic_oauth: false, github_copilot: false, + openai_codex: false, chat_compat: Default::default(), extra_headers: extra_headers .iter() diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index 6fcc42a..ec78db6 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -6894,7 +6894,7 @@ pub(crate) mod tests { #[test] fn pending_menu_items_lists_interactive_methods_plus_quit() { let items = pending_menu_items(&fresh_user_auth_methods(), None); - assert_eq!(items.len(), 29, "28 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 30, "29 login rows + Quit, got {items:?}"); assert!( matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), "row 0 must be the OAuth login, got {:?}", @@ -6915,22 +6915,27 @@ pub(crate) mod tests { "row 3 must be the github-copilot OAuth login (after claude-pro-max), got {:?}", items[3] ); + assert!( + matches!(&items[4], PendingMenuItem::Login { label } if label == "ChatGPT Plus/Pro (Codex) (OAuth)"), + "row 4 must be the openai-codex OAuth login (after github-copilot), got {:?}", + items[4] + ); assert_eq!( - items[4], + items[5], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn), label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(), } ); assert_eq!( - items[5], + items[6], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi), label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(), } ); assert_eq!( - items[6], + items[7], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi), label: "OpenAI (API key)".into(), @@ -6938,153 +6943,153 @@ pub(crate) mod tests { "new registry rows must appear in the picker with zero TUI changes" ); assert_eq!( - items[7], + items[8], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic), label: "Anthropic (API key)".into(), } ); assert_eq!( - items[8], + items[9], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek), label: "DeepSeek (API key)".into(), } ); assert_eq!( - items[9], + items[10], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Groq), label: "Groq (API key)".into(), } ); assert_eq!( - items[10], + items[11], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Mistral), label: "Mistral (API key)".into(), } ); assert_eq!( - items[11], + items[12], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks), label: "Fireworks AI (API key)".into(), } ); assert_eq!( - items[12], + items[13], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Google), label: "Google Gemini (API key)".into(), } ); assert_eq!( - items[13], + items[14], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter), label: "OpenRouter (API key)".into(), } ); assert_eq!( - items[14], + items[15], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Together), label: "Together AI (API key)".into(), } ); assert_eq!( - items[15], + items[16], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras), label: "Cerebras (API key)".into(), } ); assert_eq!( - items[16], + items[17], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia), label: "NVIDIA NIM (API key)".into(), } ); assert_eq!( - items[17], + items[18], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Vercel), label: "Vercel AI Gateway (API key)".into(), } ); assert_eq!( - items[18], + items[19], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Xai), label: "xAI (Grok) (API key)".into(), } ); assert_eq!( - items[19], + items[20], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan), label: "Qwen Token Plan (API key)".into(), } ); assert_eq!( - items[20], + items[21], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn), label: "Qwen Token Plan China (API key)".into(), } ); assert_eq!( - items[21], + items[22], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding), label: "Kimi For Coding (API key)".into(), } ); assert_eq!( - items[22], + items[23], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Zai), label: "Z.AI (API key)".into(), } ); assert_eq!( - items[23], + items[24], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn), label: "Z.AI Coding China (API key)".into(), } ); assert_eq!( - items[24], + items[25], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi), label: "Xiaomi MiMo (API key)".into(), } ); assert_eq!( - items[25], + items[26], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn), label: "Xiaomi Token Plan China (API key)".into(), } ); assert_eq!( - items[26], + items[27], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Minimax), label: "MiniMax (API key)".into(), } ); assert_eq!( - items[27], + items[28], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn), label: "MiniMax China (API key)".into(), } ); - assert_eq!(items[28], PendingMenuItem::Quit); + assert_eq!(items[29], PendingMenuItem::Quit); // The non-interactive methods must never appear as rows. let byok = kigi_shell::agent::auth_method::build_auth_methods( kigi_shell::agent::auth_method::AuthMethodsBuildInputs { @@ -7095,8 +7100,8 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 29, - "xai.api_key / cached_token must not add rows (28 login rows + Quit)" + 30, + "xai.api_key / cached_token must not add rows (29 login rows + Quit)" ); } /// Startup lands on the picker only when there is a real choice: the @@ -7118,8 +7123,10 @@ pub(crate) mod tests { app.auth_state = AuthState::Pending { error: None }; app.welcome_prompt_focused = false; // Interactive OAuth logins come first: row 0 (kimi-code), row 1 - // (xai-grok), row 2 (claude-pro-max), row 3 (github-copilot); the first - // API-key row (moonshot-cn) is now row 4, so five Downs land on it. + // (xai-grok), row 2 (claude-pro-max), row 3 (github-copilot), row 4 + // (openai-codex); the first API-key row (moonshot-cn) is now row 5, so + // six Downs land on it. + app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); diff --git a/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs b/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs index bb8bd03..e90d918 100644 --- a/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs +++ b/crates/codegen/kigi-tui/src/slash/commands/effort_levels.rs @@ -25,6 +25,7 @@ pub(crate) fn effort_description(level: ReasoningEffort) -> &'static str { ReasoningEffort::High => "Heavy reasoning", ReasoningEffort::Xhigh => "Extra-heavy reasoning", ReasoningEffort::Max => "Maximum reasoning", + ReasoningEffort::Ultra => "Ultra reasoning", } } diff --git a/crates/codegen/kigi-tui/src/views/welcome/mod.rs b/crates/codegen/kigi-tui/src/views/welcome/mod.rs index 8ca1753..27c0647 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/mod.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/mod.rs @@ -2118,6 +2118,10 @@ mod tests { text.contains("GitHub Copilot (subscription) (OAuth)"), "the github-copilot interactive OAuth login row must render: {text}" ); + assert!( + text.contains("ChatGPT Plus/Pro (Codex) (OAuth)"), + "the openai-codex interactive OAuth login row must render: {text}" + ); assert!( text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"), "{text}"