feat(providers): add ChatGPT/Codex subscription OAuth (PKCE + account-id, hardcoded catalog)

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).
This commit is contained in:
2026-07-22 08:28:03 -04:00
parent 8179438278
commit 422e241e13
27 changed files with 1294 additions and 106 deletions
+49 -14
View File
@@ -182,17 +182,50 @@ edges stay deterministic Rust. The harness appends a terminal
- `OAuthFlow::DeviceCode``auth::oauth_device` (RFC-8628 device-code, plain - `OAuthFlow::DeviceCode``auth::oauth_device` (RFC-8628 device-code, plain
kigi UA, no X-Msh headers). Provider: `xai-grok` (`scope_key oauth/xai`, 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). base `api.x.ai/v1`, form token body, same wire as the API-key `xai` row).
- `OAuthFlow::PkceLocalhost { redirect_port }` `auth::oauth_pkce` - `OAuthFlow::PkceLocalhost { redirect_port, redirect_path }`
(authorization-code + PKCE S256, `127.0.0.1:redirect_port/callback` loopback `auth::oauth_pkce` (authorization-code + PKCE S256,
with STRICT `state` validation + manual-paste fallback, JSON token body, `127.0.0.1:redirect_port{redirect_path}` loopback with STRICT `state`
authorize host ≠ token host). Provider: `claude-pro-max` (`scope_key validation + manual-paste fallback, authorize host possibly ≠ token host).
oauth/claude-pro-max`, base `api.anthropic.com/v1`, Anthropic Messages + The `token_body` selects the login dialect: `Json` = claude (`state ==
listing wire reached with an OAuth `sk-ant-oat` Bearer). Its Messages verifier`, JSON exchange carrying `state`), `Form` = codex (fresh-random
requests take the OAuth adaptation — `anthropic-beta claude-code-…,oauth-…` `state`, FORM exchange without `state` via `exchange_code_form`).
+ `claude-cli` UA + `x-app cli` + the required "You are Claude Code…" system `OAuthConfig.authorize_extra` appends provider-only authorize params (empty
prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max only), so for all but codex). Providers:
API-key `anthropic`/`minimax` Messages requests stay byte-identical. Its - `claude-pro-max` (`scope_key oauth/claude-pro-max`, port 53692
`/v1/models` listing rides the same Bearer + oauth-beta headers. `/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). - `OAuthFlow::GithubDeviceCopilot``auth::github_copilot` (TWO-STAGE).
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an `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. state and is unverifiable without a live Copilot account), not a silent gap.
These are INTERACTIVE login rows advertised right after `kimi-code` These are INTERACTIVE login rows advertised right after `kimi-code`
(`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok`, (`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 token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
`platform.oauth().is_some()``platform.base_url()` (kimi-code alone → `platform.oauth().is_some()``platform.base_url()` (kimi-code alone →
`proxy_url()`). Tokens/codes/verifiers are NEVER logged. `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 `parse_api_json` for bundled + runtime refresh; 24h cache
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment `~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
never invents model availability. Canonical reasoning efforts: never invents model availability. Canonical reasoning efforts:
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
2026-07; Kimi wire spells its top tier `max`, kimi_compat renames). 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) ## Milestones (PRD §8.3)
Generated
+1
View File
@@ -6158,6 +6158,7 @@ version = "0.1.3"
dependencies = [ dependencies = [
"assert_matches", "assert_matches",
"async-openai", "async-openai",
"base64",
"indexmap", "indexmap",
"kigi-compaction", "kigi-compaction",
"kigi-tools", "kigi-tools",
+361 -5
View File
@@ -107,8 +107,12 @@ pub enum OAuthFlow {
DeviceCode, DeviceCode,
/// Authorization-code + PKCE (S256): browser hits `auth_host`+`device_path` /// Authorization-code + PKCE (S256): browser hits `auth_host`+`device_path`
/// (the authorize endpoint); the code returns to a `127.0.0.1:redirect_port` /// (the authorize endpoint); the code returns to a `127.0.0.1:redirect_port`
/// loopback listener, then is exchanged at `token_host`+`token_path`. /// loopback listener answering `redirect_path` (claude `/callback`, codex
PkceLocalhost { redirect_port: u16 }, /// `/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 /// 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 /// on `auth_host` (github.com) mints the DURABLE GitHub token, which is then
/// exchanged at `copilot_exchange` for the SHORT-LIVED copilot session /// 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 /// A non-standard extra form field sent ONLY on the device-authorization
/// request (e.g. `("referrer", "kigi")`). `None` = no extra field. /// request (e.g. `("referrer", "kigi")`). `None` = no extra field.
pub extra_device_field: Option<(&'static str, &'static str)>, 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). /// Interactive login mechanism (device-code vs PKCE-localhost).
pub flow: OAuthFlow, pub flow: OAuthFlow,
/// Body encoding the token endpoint expects (form vs JSON). /// 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), /// "refresh". `None` for the standard flows (xai-grok, claude-pro-max),
/// whose refresh is a plain `refresh_token` grant against `token_host`. /// whose refresh is a plain `refresh_token` grant against `token_host`.
pub copilot_exchange: Option<(&'static str, &'static str)>, 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 /// 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: "openid profile email offline_access grok-cli:access api:access",
scope_key: "oauth/xai", scope_key: "oauth/xai",
extra_device_field: Some(("referrer", "kigi")), extra_device_field: Some(("referrer", "kigi")),
authorize_extra: &[],
flow: OAuthFlow::DeviceCode, flow: OAuthFlow::DeviceCode,
token_body: OAuthTokenBody::Form, token_body: OAuthTokenBody::Form,
copilot_exchange: None, copilot_exchange: None,
requires_chatgpt_account_id: false,
}; };
/// Base-URL override for the Claude Pro/Max OAuth channel (dev/test escape /// 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: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
scope_key: "oauth/claude-pro-max", scope_key: "oauth/claude-pro-max",
extra_device_field: None, extra_device_field: None,
authorize_extra: &[],
flow: OAuthFlow::PkceLocalhost { flow: OAuthFlow::PkceLocalhost {
redirect_port: 53692, redirect_port: 53692,
redirect_path: "/callback",
}, },
token_body: OAuthTokenBody::Json, token_body: OAuthTokenBody::Json,
copilot_exchange: None, copilot_exchange: None,
requires_chatgpt_account_id: false,
}; };
/// Base-URL override for the GitHub Copilot inference/listing channel /// 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: "read:user",
scope_key: "oauth/github-copilot", scope_key: "oauth/github-copilot",
extra_device_field: None, extra_device_field: None,
authorize_extra: &[],
flow: OAuthFlow::GithubDeviceCopilot, flow: OAuthFlow::GithubDeviceCopilot,
token_body: OAuthTokenBody::GithubCopilotExchange, token_body: OAuthTokenBody::GithubCopilotExchange,
copilot_exchange: Some(("https://api.github.com", "/copilot_internal/v2/token")), 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 /// 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, 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 /// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2). /// dynamic provider registration (PRD F2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] #[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 /// GitHub Copilot subscription via the two-stage device-code OAuth flow
/// (ChatCompletions wire reached with the short-lived copilot token). /// (ChatCompletions wire reached with the short-lived copilot token).
GithubCopilot, 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 { impl PlatformId {
/// All platforms, in catalog precedence order: the subscription channel /// All platforms, in catalog precedence order: the subscription channel
/// first so "default model = first list item" favors it when present. /// first so "default model = first list item" favors it when present.
pub const ALL: [PlatformId; 28] = [ pub const ALL: [PlatformId; 29] = [
Self::KimiCode, Self::KimiCode,
Self::MoonshotCn, Self::MoonshotCn,
Self::MoonshotAi, Self::MoonshotAi,
@@ -1280,6 +1380,7 @@ impl PlatformId {
Self::XaiGrok, Self::XaiGrok,
Self::ClaudeProMax, Self::ClaudeProMax,
Self::GithubCopilot, Self::GithubCopilot,
Self::OpenaiCodex,
]; ];
/// The registry row backing this platform (single source of per-platform /// The registry row backing this platform (single source of per-platform
@@ -1314,6 +1415,7 @@ impl PlatformId {
Self::XaiGrok => &XAI_GROK_SPEC, Self::XaiGrok => &XAI_GROK_SPEC,
Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC, Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC,
Self::GithubCopilot => &GITHUB_COPILOT_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 { pub fn sends_copilot_editor_headers(self) -> bool {
matches!(self, Self::GithubCopilot) 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<Vec<WireModel>> {
match self {
Self::OpenaiCodex => Some(openai_codex_wire_models()),
_ => None,
}
}
}
/// One hardcoded openai-codex model as a [`WireModel`] (context 272000, thinking
/// + image input, selectable efforts). `efforts` are the exact per-model
/// supported tiers; `default` is the model's default effort.
fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &str) -> WireModel {
WireModel {
id: slug.to_string(),
context_length: 272_000,
supports_reasoning: true,
supports_image_in: true,
supports_video_in: false,
display_name: Some(display_name.to_string()),
max_output_tokens: 0,
supports_thinking_type: None,
think_efforts: Some(WireThinkEfforts {
support: true,
valid_efforts: efforts.iter().map(|s| (*s).to_string()).collect(),
default_effort: Some(default.to_string()),
}),
}
}
/// The HARDCODED openai-codex catalog: exactly the 4 `visibility=="list"` AND
/// `supported_in_api==true` models from the Codex CLI model cache. The
/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses),
/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models
/// are 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<WireModel> {
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 /// Split a managed catalog key `{platform_id}/{model_id}` back into its
@@ -2288,7 +2472,8 @@ mod tests {
assert_eq!( assert_eq!(
cfg.flow, cfg.flow,
OAuthFlow::PkceLocalhost { OAuthFlow::PkceLocalhost {
redirect_port: 53692 redirect_port: 53692,
redirect_path: "/callback",
} }
); );
assert_eq!(cfg.token_body, OAuthTokenBody::Json); assert_eq!(cfg.token_body, OAuthTokenBody::Json);
@@ -2379,6 +2564,176 @@ mod tests {
assert_eq!(g.base_url(), "https://mock.copilot"); 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<String> {
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<String> {
catalog
.iter()
.find(|m| m.id == slug)
.unwrap()
.think_efforts
.as_ref()
.unwrap()
.default_effort
.clone()
};
assert_eq!(default("gpt-5.6-sol").as_deref(), Some("low"));
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
// Only openai-codex has a hardcoded catalog; every wire platform is None.
for p in PlatformId::ALL {
if p != PlatformId::OpenaiCodex {
assert!(
p.hardcoded_catalog().is_none(),
"{} must not carry a hardcoded catalog",
p.as_str()
);
}
}
}
/// The Copilot `/models` filter keeps ONLY openai-completions-served, /// The Copilot `/models` filter keeps ONLY openai-completions-served,
/// selectable, tool-calling models — dropping the claude-4.x/5.x (messages) /// 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 / /// and gpt-5/oswe/mai- (responses-only) ids AND the disabled / picker-off /
@@ -2464,9 +2819,10 @@ mod tests {
PlatformId::XaiGrok => 25, PlatformId::XaiGrok => 25,
PlatformId::ClaudeProMax => 26, PlatformId::ClaudeProMax => 26,
PlatformId::GithubCopilot => 27, 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<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable(); seen.sort_unstable();
seen.dedup(); seen.dedup();
@@ -91,6 +91,7 @@ mod tests {
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 8192, context_window: 8192,
+150
View File
@@ -304,6 +304,9 @@ struct ClientDefaults {
doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>, doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>,
/// Claude Pro/Max OAuth Messages adaptation (see [`SamplerConfig`]). /// Claude Pro/Max OAuth Messages adaptation (see [`SamplerConfig`]).
anthropic_oauth: bool, 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 // Apply all extra headers verbatim. This is the single
// injection point for proxy-auth headers and any other URL- or // injection point for proxy-auth headers and any other URL- or
// environment-specific headers the session decides to set. // environment-specific headers the session decides to set.
@@ -503,6 +524,8 @@ impl SamplingClient {
kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string() kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string()
} else if config.github_copilot { } else if config.github_copilot {
kigi_sampling_types::COPILOT_USER_AGENT.to_string() kigi_sampling_types::COPILOT_USER_AGENT.to_string()
} else if config.openai_codex {
kigi_sampling_types::CODEX_USER_AGENT.to_string()
} else { } else {
match config.origin_client.as_ref() { match config.origin_client.as_ref() {
Some(origin) => user_agent_string_for(origin), Some(origin) => user_agent_string_for(origin),
@@ -551,6 +574,7 @@ impl SamplingClient {
stream_tool_calls: config.stream_tool_calls, stream_tool_calls: config.stream_tool_calls,
doom_loop_recovery: config.doom_loop_recovery, doom_loop_recovery: config.doom_loop_recovery,
anthropic_oauth: config.anthropic_oauth, anthropic_oauth: config.anthropic_oauth,
openai_codex: config.openai_codex,
}; };
Ok(Self { 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 let auth_prefix = headers
.get(AUTHORIZATION) .get(AUTHORIZATION)
@@ -699,6 +740,9 @@ impl SamplingClient {
|| lower.contains("apikey") || lower.contains("apikey")
|| lower.contains("token") || lower.contains("token")
|| lower.contains("secret") || 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. /// Format a single header for error messages, redacting sensitive values.
@@ -1990,6 +2034,7 @@ mod tests {
auth_scheme: AuthScheme::Bearer, auth_scheme: AuthScheme::Bearer,
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 8192, 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 /// REGRESSION: a plain ChatCompletions client (github-copilot OFF, standing
/// in for groq) carries NONE of the Copilot editor headers and keeps the /// in for groq) carries NONE of the Copilot editor headers and keeps the
/// kigi User-Agent — every other ChatCompletions provider stays untouched. /// 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` /// The system-prompt prefix is prepended as a distinct leading `text`
/// block for each `system` shape (absent / string / blocks), preserving the /// block for each `system` shape (absent / string / blocks), preserving the
/// caller's prompt, and is idempotent (not stamped twice). /// caller's prompt, and is idempotent (not stamped twice).
@@ -70,6 +70,14 @@ pub struct SamplerConfig {
/// byte-identical. /// byte-identical.
#[serde(default)] #[serde(default)]
pub github_copilot: bool, 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 /// Extra request headers applied verbatim. The sampler never inspects
/// the URL to derive headers; callers (the session) inject proxy auth /// the URL to derive headers; callers (the session) inject proxy auth
/// and other access headers here before constructing the config. /// and other access headers here before constructing the config.
@@ -161,6 +169,7 @@ impl Default for SamplerConfig {
auth_scheme: AuthScheme::default(), auth_scheme: AuthScheme::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 0, context_window: 0,
force_http1: false, force_http1: false,
@@ -80,6 +80,7 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig {
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 128_000, context_window: 128_000,
@@ -7,6 +7,7 @@ description = "Pure data types for the xAI sampling / chat-completion API layer"
[dependencies] [dependencies]
async-openai = { workspace = true } async-openai = { workspace = true }
base64 = { workspace = true }
indexmap = { workspace = true, features = ["serde"] } indexmap = { workspace = true, features = ["serde"] }
reqwest = { workspace = true } reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
+147 -3
View File
@@ -913,6 +913,11 @@ pub enum ReasoningEffort {
/// Messages both accept `xhigh` AND `max` as separate levels in 2026; /// Messages both accept `xhigh` AND `max` as separate levels in 2026;
/// the Kimi wire spells its top tier `max` with no `xhigh`). /// the Kimi wire spells its top tier `max` with no `xhigh`).
Max, 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 { impl ReasoningEffort {
@@ -941,12 +946,15 @@ impl ReasoningEffort {
Self::High => "high", Self::High => "high",
Self::Xhigh => "xhigh", Self::Xhigh => "xhigh",
Self::Max => "max", Self::Max => "max",
Self::Ultra => "ultra",
} }
} }
/// Anthropic Messages API effort string; `None` for unsupported variants. /// Anthropic Messages API effort string; `None` for unsupported variants.
/// `xhigh` and `max` are distinct levels on the 2026 Messages API (both /// `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> { pub fn to_messages_api(self) -> Option<&'static str> {
match self { match self {
Self::None | Self::Minimal => None, Self::None | Self::Minimal => None,
@@ -955,6 +963,7 @@ impl ReasoningEffort {
Self::High => Some("high"), Self::High => Some("high"),
Self::Xhigh => Some("xhigh"), Self::Xhigh => Some("xhigh"),
Self::Max => Some("max"), Self::Max => Some("max"),
Self::Ultra => Some("ultra"),
} }
} }
} }
@@ -977,8 +986,9 @@ impl std::str::FromStr for ReasoningEffort {
"high" => Ok(Self::High), "high" => Ok(Self::High),
"xhigh" => Ok(Self::Xhigh), "xhigh" => Ok(Self::Xhigh),
"max" => Ok(Self::Max), "max" => Ok(Self::Max),
"ultra" => Ok(Self::Ultra),
_ => Err(format!( _ => 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). /// `X-Initiator` value — sent ONLY on inference (`user`, per the spec).
pub const COPILOT_INITIATOR: &str = "user"; 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<String> {
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 /// ChatCompletions request-body adaptation dialect. Providers disagree on
/// how thinking rides an OpenAI-compatible body: Kimi wants /// how thinking rides an OpenAI-compatible body: Kimi wants
/// `thinking:{type,effort}`, DeepSeek wants /// `thinking:{type,effort}`, DeepSeek wants
@@ -1633,6 +1696,86 @@ mod tests {
assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max")); 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::<ReasoningEffort>().unwrap(),
ReasoningEffort::Ultra
);
assert_eq!(
"ULTRA".parse::<ReasoningEffort>().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::<ReasoningEffort>("\"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] #[test]
fn parse_canonical_effort_token_helper() { fn parse_canonical_effort_token_helper() {
assert_eq!( assert_eq!(
@@ -1797,7 +1940,8 @@ mod tests {
); );
let bad_type = as_map(serde_json::json!({"reasoningEffort": 3})); let bad_type = as_map(serde_json::json!({"reasoningEffort": 3}));
assert_eq!(parse_reasoning_effort_meta(Some(&bad_type)), None); 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); assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None);
} }
@@ -605,6 +605,11 @@ mod tests {
); );
assert_eq!( assert_eq!(
ids[kimi_pos + 4], 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, MOONSHOT_CN_METHOD_ID,
"the api-key rows follow the generic oauth logins" "the api-key rows follow the generic oauth logins"
); );
@@ -756,6 +761,7 @@ mod tests {
"xai-grok", "xai-grok",
"claude-pro-max", "claude-pro-max",
"github-copilot", "github-copilot",
"openai-codex",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -807,6 +813,7 @@ mod tests {
"xai-grok", "xai-grok",
"claude-pro-max", "claude-pro-max",
"github-copilot", "github-copilot",
"openai-codex",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -851,6 +858,7 @@ mod tests {
"xai-grok", "xai-grok",
"claude-pro-max", "claude-pro-max",
"github-copilot", "github-copilot",
"openai-codex",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -898,6 +906,7 @@ mod tests {
"xai-grok", "xai-grok",
"claude-pro-max", "claude-pro-max",
"github-copilot", "github-copilot",
"openai-codex",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -4099,6 +4099,15 @@ pub fn sampling_config_for_model(
.as_deref() .as_deref()
.and_then(kigi_models::parse_managed_model_key) .and_then(kigi_models::parse_managed_model_key)
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()); .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 { SamplerConfig {
api_key: credentials.api_key, api_key: credentials.api_key,
model: model_name, model: model_name,
@@ -4110,6 +4119,7 @@ pub fn sampling_config_for_model(
auth_scheme: credentials.auth_scheme, auth_scheme: credentials.auth_scheme,
anthropic_oauth, anthropic_oauth,
github_copilot, github_copilot,
openai_codex,
chat_compat, chat_compat,
extra_headers, extra_headers,
context_window: info.context_window.get(), context_window: info.context_window.get(),
@@ -334,6 +334,19 @@ fn fetch_one_platform_models(
bearer: &str, bearer: &str,
enrichment: &kigi_models::enrichment::EnrichmentCatalog, enrichment: &kigi_models::enrichment::EnrichmentCatalog,
) -> Result<(Vec<crate::agent::config::ModelEntryConfig>, Option<String>), BackendError> { ) -> Result<(Vec<crate::agent::config::ModelEntryConfig>, Option<String>), BackendError> {
// A platform that serves NO live `/models` listing (openai-codex) delivers a
// HARDCODED catalog: short-circuit BEFORE any HTTP, mapping the compiled-in
// `WireModel`s through the SAME `platform_wire_model_to_entry` output a live
// listing produces (context window + per-model reasoning efforts). The
// bearer is unused here (login gates availability; no request is made).
if let Some(wire_models) = platform.hardcoded_catalog() {
let base_url = platform_fetch_base(platform, endpoints);
let models = wire_models
.into_iter()
.map(|wire| platform_wire_model_to_entry(platform, wire, &base_url))
.collect();
return Ok((models, None));
}
let client = crate::http::shared_blocking_client(); let client = crate::http::shared_blocking_client();
let url = match platform.listing() { let url = match platform.listing() {
kigi_models::ListingDialect::OpenAi => platform_models_url(platform, endpoints), 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/<slug>` 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<_>>(),
vec![
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
"openai-codex/gpt-5.5",
],
"exactly the 4 hardcoded models, keyed openai-codex/<slug>"
);
// 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<_>>(),
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<_>>(),
vec!["low", "medium", "high", "xhigh"]
);
}
/// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts /// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts
/// (high/max) produce ChatCompletions entries whose sampler config /// (high/max) produce ChatCompletions entries whose sampler config
/// speaks the DeepSeek thinking dialect. /// speaks the DeepSeek thinking dialect.
@@ -60,6 +60,7 @@ fn effort_label(effort: ReasoningEffort) -> String {
ReasoningEffort::High => "High", ReasoningEffort::High => "High",
ReasoningEffort::Xhigh => "X-High", ReasoningEffort::Xhigh => "X-High",
ReasoningEffort::Max => "Max", ReasoningEffort::Max => "Max",
ReasoningEffort::Ultra => "Ultra",
} }
.to_string() .to_string()
} }
@@ -893,6 +893,11 @@ async fn read_parent_sampling_config(
// false, so the other ChatCompletions paths stay byte-identical. // false, so the other ChatCompletions paths stay byte-identical.
let github_copilot = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref()) let github_copilot = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()); .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 { let inherited = kigi_sampler::SamplerConfig {
api_key: creds.api_key, api_key: creds.api_key,
base_url: cfg.base_url, base_url: cfg.base_url,
@@ -904,6 +909,7 @@ async fn read_parent_sampling_config(
auth_scheme, auth_scheme,
anthropic_oauth, anthropic_oauth,
github_copilot, github_copilot,
openai_codex,
chat_compat: cfg.chat_compat, chat_compat: cfg.chat_compat,
extra_headers, extra_headers,
context_window: cfg.context_window.get(), context_window: cfg.context_window.get(),
+59 -15
View File
@@ -104,8 +104,18 @@ pub async fn run_oauth_provider_flow(
) )
.await .await
} }
kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => { kigi_models::OAuthFlow::PkceLocalhost {
run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await redirect_port,
redirect_path,
} => {
run_pkce_localhost_login(
oauth,
redirect_port,
redirect_path,
auth_manager,
&mut channels,
)
.await
} }
kigi_models::OAuthFlow::GithubDeviceCopilot => { kigi_models::OAuthFlow::GithubDeviceCopilot => {
crate::auth::device_code::run_device_code_login_github_copilot( 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 /// PKCE-localhost login (claude-pro-max JSON, openai-codex FORM): generate PKCE,
/// authorize URL (TUI channel or stderr), open the browser, then await the code /// present the browser authorize URL (TUI channel or stderr), open the browser,
/// from EITHER the `127.0.0.1:{redirect_port}` loopback callback OR a manual /// then await the code from EITHER the `127.0.0.1:{redirect_port}{redirect_path}`
/// paste (headless fallback). Exchange it at the token endpoint and persist. /// 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 /// The `token_body` selects the wire dialect: `Json` is the claude path
/// `127.0.0.1` only and validates `state` strictly. /// (`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( async fn run_pkce_localhost_login(
oauth: &'static kigi_models::OAuthConfig, oauth: &'static kigi_models::OAuthConfig,
redirect_port: u16, redirect_port: u16,
redirect_path: &'static str,
auth_manager: &Arc<AuthManager>, auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>, channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> { ) -> anyhow::Result<(KimiAuth, bool)> {
use crate::auth::oauth_pkce; use crate::auth::oauth_pkce;
let pkce = oauth_pkce::generate_pkce(); // The token-body encoding also selects the PKCE `state` convention: the JSON
let redirect = oauth_pkce::redirect_uri(redirect_port); // 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 authorize_url = oauth_pkce::build_authorize_url(oauth, &redirect, &pkce);
let mut chans = channels.take(); 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; crate::auth::device_code::open_browser_detached(&authorize_url).await;
} else { } else {
eprintln!(); 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!();
eprintln!(" {authorize_url}"); eprintln!(" {authorize_url}");
eprintln!(); eprintln!();
@@ -159,8 +186,24 @@ async fn run_pkce_localhost_login(
eprintln!("Waiting for the sign-in to complete..."); eprintln!("Waiting for the sign-in to complete...");
} }
let code = await_pkce_code(redirect_port, &pkce, chans.as_mut()).await?; let code = await_pkce_code(redirect_port, redirect_path, &pkce, chans.as_mut()).await?;
let auth = oauth_pkce::exchange_code(oauth, &code, &pkce, &redirect).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 let auth = auth_manager
.update(auth) .update(auth)
.await .await
@@ -174,6 +217,7 @@ async fn run_pkce_localhost_login(
/// arms (strict on the loopback, mismatch-rejecting on the paste). /// arms (strict on the loopback, mismatch-rejecting on the paste).
async fn await_pkce_code( async fn await_pkce_code(
redirect_port: u16, redirect_port: u16,
redirect_path: &str,
pkce: &crate::auth::oauth_pkce::PkceCodes, pkce: &crate::auth::oauth_pkce::PkceCodes,
channels: Option<&mut AuthChannels>, channels: Option<&mut AuthChannels>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
@@ -181,7 +225,7 @@ async fn await_pkce_code(
match channels { match channels {
Some(ch) => { Some(ch) => {
tokio::select! { 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() => { pasted = ch.code_rx.recv() => {
let pasted = pasted let pasted = pasted
.ok_or_else(|| anyhow::anyhow!("auth code channel closed before a code arrived"))?; .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,
} }
} }
+253 -36
View File
@@ -1,18 +1,24 @@
//! Generic authorization-code + PKCE (S256) OAuth wire with a `127.0.0.1` //! Generic authorization-code + PKCE (S256) OAuth wire with a `127.0.0.1`
//! loopback callback, driven by a registry [`kigi_models::OAuthConfig`] whose //! 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 = 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& //! - 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}` //! - The code returns to a loopback listener on `127.0.0.1:{redirect_port}`
//! answering ONLY `/callback`, with STRICT `state` validation (a mismatch is //! answering ONLY `{redirect_path}` (claude `/callback`, codex
//! rejected — CSRF guard). A manual paste (redirect URL / `code#state` / bare //! `/auth/callback`), with STRICT `state` validation (a mismatch is rejected —
//! code) is accepted as a headless fallback. //! CSRF guard). A manual paste (redirect URL / `code#state` / bare code) is
//! - Code → token exchange and refresh POST `{token_host}{token_path}` as JSON //! accepted as a headless fallback.
//! (per `token_body`); the refresh token ROTATES. //! - 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 //! SECURITY: the verifier, authorization code, access token, and refresh token
//! are NEVER logged (only non-secret events: authorize URL requested, callback //! 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). /// HTTP statuses worth retrying a refresh for (parity with the device wire).
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504]; const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
/// PKCE secrets for one login attempt. `state == verifier` (Pi's convention: /// PKCE secrets for one login attempt. Two `state` conventions ship:
/// the state is the verifier, so a returned state binds the callback to this /// [`generate_pkce`] sets `state == verifier` (Pi's/Claude's convention), while
/// attempt AND doubles as the CSRF token). /// [`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)] #[derive(Debug, Clone)]
pub(crate) struct PkceCodes { pub(crate) struct PkceCodes {
/// `code_verifier` — the 43-char base64url secret, sent at token exchange. /// `code_verifier` — the 43-char base64url secret, sent at token exchange.
pub verifier: String, pub verifier: String,
/// `code_challenge = base64url(SHA-256(verifier))`, sent at authorize. /// `code_challenge = base64url(SHA-256(verifier))`, sent at authorize.
pub challenge: String, 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, pub state: String,
} }
@@ -63,30 +72,51 @@ pub(crate) fn generate_pkce() -> PkceCodes {
} }
} }
/// The loopback redirect URI for a PKCE-localhost provider. /// Like [`generate_pkce`] but with an INDEPENDENT fresh-random `state` (16
pub(crate) fn redirect_uri(redirect_port: u16) -> String { /// random bytes) instead of `state == verifier`. The ChatGPT/Codex flow uses a
format!("http://localhost:{redirect_port}/callback") /// 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: /// Build the browser authorize URL:
/// `{auth_host}{device_path}?client_id&response_type=code&scope&redirect_uri& /// `{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( pub(crate) fn build_authorize_url(
cfg: &OAuthConfig, cfg: &OAuthConfig,
redirect_uri: &str, redirect_uri: &str,
pkce: &PkceCodes, pkce: &PkceCodes,
) -> String { ) -> String {
let base = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.device_path); 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("client_id", cfg.client_id)
.append_pair("response_type", "code") .append_pair("response_type", "code")
.append_pair("scope", cfg.scope) .append_pair("scope", cfg.scope)
.append_pair("redirect_uri", redirect_uri) .append_pair("redirect_uri", redirect_uri)
.append_pair("state", &pkce.state) .append_pair("state", &pkce.state)
.append_pair("code_challenge", &pkce.challenge) .append_pair("code_challenge", &pkce.challenge)
.append_pair("code_challenge_method", "S256") .append_pair("code_challenge_method", "S256");
.finish(); for (key, value) in cfg.authorize_extra {
format!("{base}?{query}") serializer.append_pair(key, value);
}
format!("{base}?{}", serializer.finish())
} }
/// `code` + `state` extracted from a callback (loopback query OR manual paste). /// `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()) 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<KimiAuth> {
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). /// `POST {token_host}{token_path}` with `grant_type=refresh_token` (JSON body).
/// Claude ROTATES the refresh token, so the caller MUST persist the returned /// Claude ROTATES the refresh token, so the caller MUST persist the returned
/// one. Retries the retryable statuses / network errors with exponential /// 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 /// 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 /// single `GET {redirect_path}?code=…&state=…`, validating `state` STRICTLY
/// `expected_state` (mismatch → rejected). Returns the authorization code. /// against `expected_state` (mismatch → rejected). Returns the authorization
/// code.
/// ///
/// The listener answers ONLY `/callback`; any other path gets 404. It binds /// The listener answers ONLY `redirect_path` (claude `/callback`, codex
/// `127.0.0.1` (never `0.0.0.0`), so no non-loopback host can reach it. /// `/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( pub(crate) async fn await_loopback_code(
redirect_port: u16, redirect_port: u16,
redirect_path: &str,
expected_state: &str, expected_state: &str,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", redirect_port)) 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"); tracing::info!(port = redirect_port, "auth: pkce loopback listener bound");
loop { loop {
let (stream, _peer) = listener.accept().await.context("loopback accept failed")?; 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::Code(code) => return Ok(code),
LoopbackOutcome::Rejected(err) => return Err(err), 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, LoopbackOutcome::Ignore => continue,
} }
} }
@@ -355,6 +434,7 @@ enum LoopbackOutcome {
/// state is [`LoopbackOutcome::Rejected`] (the browser sees an error page). /// state is [`LoopbackOutcome::Rejected`] (the browser sees an error page).
async fn handle_loopback_conn( async fn handle_loopback_conn(
mut stream: tokio::net::TcpStream, mut stream: tokio::net::TcpStream,
redirect_path: &str,
expected_state: &str, expected_state: &str,
) -> LoopbackOutcome { ) -> LoopbackOutcome {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -370,7 +450,7 @@ async fn handle_loopback_conn(
let Some(request_line) = head.lines().next() else { let Some(request_line) = head.lines().next() else {
return LoopbackOutcome::Ignore; 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 mut parts = request_line.split_whitespace();
let (Some(method), Some(target)) = (parts.next(), parts.next()) else { let (Some(method), Some(target)) = (parts.next(), parts.next()) else {
return LoopbackOutcome::Ignore; return LoopbackOutcome::Ignore;
@@ -380,7 +460,7 @@ async fn handle_loopback_conn(
return LoopbackOutcome::Ignore; return LoopbackOutcome::Ignore;
} }
let (path, query) = target.split_once('?').unwrap_or((target, "")); 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; let _ = write_http(&mut stream, 404, "Not Found").await;
return LoopbackOutcome::Ignore; return LoopbackOutcome::Ignore;
} }
@@ -392,7 +472,7 @@ async fn handle_loopback_conn(
let _ = write_http( let _ = write_http(
&mut stream, &mut stream,
200, 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; .await;
let _ = stream.flush().await; let _ = stream.flush().await;
@@ -473,7 +553,7 @@ mod tests {
#[test] #[test]
fn authorize_url_has_state_and_s256_challenge() { fn authorize_url_has_state_and_s256_challenge() {
let pkce = generate_pkce(); 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 url = build_authorize_url(&CLAUDE_OAUTH_CONFIG, &redirect, &pkce);
let parsed = url::Url::parse(&url).expect("valid URL"); let parsed = url::Url::parse(&url).expect("valid URL");
assert_eq!(parsed.host_str(), Some("claude.ai")); assert_eq!(parsed.host_str(), Some("claude.ai"));
@@ -545,7 +625,10 @@ mod tests {
let port = probe.local_addr().unwrap().port(); let port = probe.local_addr().unwrap().port();
drop(probe); 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. // Give the listener a moment to bind.
tokio::time::sleep(std::time::Duration::from_millis(50)).await; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Attacker callback: valid code, WRONG state. // Attacker callback: valid code, WRONG state.
@@ -567,7 +650,8 @@ mod tests {
let port = probe.local_addr().unwrap().port(); let port = probe.local_addr().unwrap().port();
drop(probe); 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; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let _ = reqwest::get(format!( let _ = reqwest::get(format!(
"http://127.0.0.1:{port}/callback?code=auth-code-123&state=good-state" "http://127.0.0.1:{port}/callback?code=auth-code-123&state=good-state"
@@ -622,9 +706,14 @@ mod tests {
.await; .await;
let cfg = mock_cfg(host); let cfg = mock_cfg(host);
let pkce = generate_pkce(); let pkce = generate_pkce();
let auth = exchange_code(&cfg, "auth-code-xyz", &pkce, &redirect_uri(53692)) let auth = exchange_code(
.await &cfg,
.unwrap(); "auth-code-xyz",
&pkce,
&redirect_uri(53692, "/callback"),
)
.await
.unwrap();
assert_eq!(auth.key, "sk-ant-oat-new"); assert_eq!(auth.key, "sk-ant-oat-new");
assert_eq!(auth.refresh_token.as_deref(), Some("sk-ant-ort-new")); assert_eq!(auth.refresh_token.as_deref(), Some("sk-ant-ort-new"));
assert_eq!(auth.expires_in, Some(3600)); assert_eq!(auth.expires_in, Some(3600));
@@ -684,4 +773,132 @@ mod tests {
other => panic!("expected Unauthorized, got {other:?}"), 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));
}
} }
@@ -153,6 +153,46 @@ mod tests {
.expect("github-copilot carries an OAuthConfig") .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/<model>` 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/<model>` turn resolves to the process-global pooled /// A `github-copilot/<model>` turn resolves to the process-global pooled
/// github-copilot manager (its OWN `oauth/github-copilot` scope), NEVER the /// github-copilot manager (its OWN `oauth/github-copilot` scope), NEVER the
/// primary Kimi manager — the same leak-safe routing as xai-grok / /// primary Kimi manager — the same leak-safe routing as xai-grok /
@@ -1,6 +1,9 @@
//! Generic device-code token refresher: drives `POST {token_path}` with //! Generic OAuth token refresher for any [`kigi_models::OAuthConfig`] provider,
//! `grant_type=refresh_token` for any [`kigi_models::OAuthConfig`] provider //! driven through the [`TokenRefresher`] seam. The wire call is selected by the
//! (xai-grok today) through the [`TokenRefresher`] seam. //! 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 //! Structurally identical to [`super::kimi_refresher::KimiRefresher`] — same
//! sibling-adoption + post-401 grace — but the wire call goes through //! sibling-adoption + post-401 grace — but the wire call goes through
@@ -118,6 +121,22 @@ impl TokenRefresher for GenericDeviceRefresher {
} }
}; };
match wire_result { 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) => { Ok(new_auth) => {
kigi_log::unified_log::info( kigi_log::unified_log::info(
"auth.refresh.token_rotated", "auth.refresh.token_rotated",
@@ -283,6 +283,16 @@ impl SessionActor {
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model)) kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()) .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 /// LEAK guard for the stamped aux paths (auto-mode classifier, image
/// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`] /// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`]
/// has copied the SESSION model's `bearer_resolver` onto an aux /// 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); let anthropic_oauth = self.model_is_anthropic_oauth(&cfg.model);
// GitHub Copilot editor-identity headers for THIS turn's model. // GitHub Copilot editor-identity headers for THIS turn's model.
let github_copilot = self.model_is_github_copilot(&cfg.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 auth_scheme = model_facts.auth_scheme;
let mut extra_headers = cfg.extra_headers; let mut extra_headers = cfg.extra_headers;
crate::agent::config::inject_url_derived_headers( crate::agent::config::inject_url_derived_headers(
@@ -436,6 +448,7 @@ impl SessionActor {
auth_scheme, auth_scheme,
anthropic_oauth, anthropic_oauth,
github_copilot, github_copilot,
openai_codex,
chat_compat: cfg.chat_compat, chat_compat: cfg.chat_compat,
extra_headers, extra_headers,
context_window: cfg.context_window.get(), context_window: cfg.context_window.get(),
@@ -851,6 +851,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 256_000, context_window: 256_000,
force_http1: false, force_http1: false,
@@ -48,6 +48,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
@@ -346,6 +347,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
@@ -478,6 +480,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
@@ -1748,6 +1751,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
@@ -1592,6 +1592,7 @@ mod reasoning_compaction_regression_tests {
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 256_000, context_window: 256_000,
force_http1: false, force_http1: false,
@@ -48,6 +48,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 256_000, context_window: 256_000,
force_http1: false, force_http1: false,
@@ -40,6 +40,7 @@ pub fn test_sampler_config(
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false, anthropic_oauth: false,
github_copilot: false, github_copilot: false,
openai_codex: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: extra_headers extra_headers: extra_headers
.iter() .iter()
+37 -30
View File
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
#[test] #[test]
fn pending_menu_items_lists_interactive_methods_plus_quit() { fn pending_menu_items_lists_interactive_methods_plus_quit() {
let items = pending_menu_items(&fresh_user_auth_methods(), None); 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!( assert!(
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
"row 0 must be the OAuth login, got {:?}", "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 {:?}", "row 3 must be the github-copilot OAuth login (after claude-pro-max), got {:?}",
items[3] 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!( assert_eq!(
items[4], items[5],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn), target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn),
label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(), label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(),
} }
); );
assert_eq!( assert_eq!(
items[5], items[6],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi), target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi),
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(), label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
} }
); );
assert_eq!( assert_eq!(
items[6], items[7],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi), target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi),
label: "OpenAI (API key)".into(), 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" "new registry rows must appear in the picker with zero TUI changes"
); );
assert_eq!( assert_eq!(
items[7], items[8],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic), target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic),
label: "Anthropic (API key)".into(), label: "Anthropic (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[8], items[9],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek), target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek),
label: "DeepSeek (API key)".into(), label: "DeepSeek (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[9], items[10],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Groq), target: PlatformLogin(kigi_shell::models::PlatformId::Groq),
label: "Groq (API key)".into(), label: "Groq (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[10], items[11],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Mistral), target: PlatformLogin(kigi_shell::models::PlatformId::Mistral),
label: "Mistral (API key)".into(), label: "Mistral (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[11], items[12],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks), target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks),
label: "Fireworks AI (API key)".into(), label: "Fireworks AI (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[12], items[13],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Google), target: PlatformLogin(kigi_shell::models::PlatformId::Google),
label: "Google Gemini (API key)".into(), label: "Google Gemini (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[13], items[14],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter), target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter),
label: "OpenRouter (API key)".into(), label: "OpenRouter (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[14], items[15],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Together), target: PlatformLogin(kigi_shell::models::PlatformId::Together),
label: "Together AI (API key)".into(), label: "Together AI (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[15], items[16],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras), target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras),
label: "Cerebras (API key)".into(), label: "Cerebras (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[16], items[17],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia), target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia),
label: "NVIDIA NIM (API key)".into(), label: "NVIDIA NIM (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[17], items[18],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Vercel), target: PlatformLogin(kigi_shell::models::PlatformId::Vercel),
label: "Vercel AI Gateway (API key)".into(), label: "Vercel AI Gateway (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[18], items[19],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Xai), target: PlatformLogin(kigi_shell::models::PlatformId::Xai),
label: "xAI (Grok) (API key)".into(), label: "xAI (Grok) (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[19], items[20],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan), target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan),
label: "Qwen Token Plan (API key)".into(), label: "Qwen Token Plan (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[20], items[21],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn), target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn),
label: "Qwen Token Plan China (API key)".into(), label: "Qwen Token Plan China (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[21], items[22],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding), target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding),
label: "Kimi For Coding (API key)".into(), label: "Kimi For Coding (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[22], items[23],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Zai), target: PlatformLogin(kigi_shell::models::PlatformId::Zai),
label: "Z.AI (API key)".into(), label: "Z.AI (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[23], items[24],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn), target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn),
label: "Z.AI Coding China (API key)".into(), label: "Z.AI Coding China (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[24], items[25],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi), target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi),
label: "Xiaomi MiMo (API key)".into(), label: "Xiaomi MiMo (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[25], items[26],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn), target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn),
label: "Xiaomi Token Plan China (API key)".into(), label: "Xiaomi Token Plan China (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[26], items[27],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Minimax), target: PlatformLogin(kigi_shell::models::PlatformId::Minimax),
label: "MiniMax (API key)".into(), label: "MiniMax (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[27], items[28],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn), target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn),
label: "MiniMax China (API key)".into(), 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. // The non-interactive methods must never appear as rows.
let byok = kigi_shell::agent::auth_method::build_auth_methods( let byok = kigi_shell::agent::auth_method::build_auth_methods(
kigi_shell::agent::auth_method::AuthMethodsBuildInputs { kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
@@ -7095,8 +7100,8 @@ pub(crate) mod tests {
); );
assert_eq!( assert_eq!(
pending_menu_items(&byok.methods, None).len(), pending_menu_items(&byok.methods, None).len(),
29, 30,
"xai.api_key / cached_token must not add rows (28 login rows + Quit)" "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 /// 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.auth_state = AuthState::Pending { error: None };
app.welcome_prompt_focused = false; app.welcome_prompt_focused = false;
// Interactive OAuth logins come first: row 0 (kimi-code), row 1 // Interactive OAuth logins come first: row 0 (kimi-code), row 1
// (xai-grok), row 2 (claude-pro-max), row 3 (github-copilot); the first // (xai-grok), row 2 (claude-pro-max), row 3 (github-copilot), row 4
// API-key row (moonshot-cn) is now row 4, so five Downs land on it. // (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)); 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));
@@ -25,6 +25,7 @@ pub(crate) fn effort_description(level: ReasoningEffort) -> &'static str {
ReasoningEffort::High => "Heavy reasoning", ReasoningEffort::High => "Heavy reasoning",
ReasoningEffort::Xhigh => "Extra-heavy reasoning", ReasoningEffort::Xhigh => "Extra-heavy reasoning",
ReasoningEffort::Max => "Maximum reasoning", ReasoningEffort::Max => "Maximum reasoning",
ReasoningEffort::Ultra => "Ultra reasoning",
} }
} }
@@ -2118,6 +2118,10 @@ mod tests {
text.contains("GitHub Copilot (subscription) (OAuth)"), text.contains("GitHub Copilot (subscription) (OAuth)"),
"the github-copilot interactive OAuth login row must render: {text}" "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!( assert!(
text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"), text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
"{text}" "{text}"