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:
@@ -107,8 +107,12 @@ pub enum OAuthFlow {
|
||||
DeviceCode,
|
||||
/// Authorization-code + PKCE (S256): browser hits `auth_host`+`device_path`
|
||||
/// (the authorize endpoint); the code returns to a `127.0.0.1:redirect_port`
|
||||
/// loopback listener, then is exchanged at `token_host`+`token_path`.
|
||||
PkceLocalhost { redirect_port: u16 },
|
||||
/// loopback listener answering `redirect_path` (claude `/callback`, codex
|
||||
/// `/auth/callback`), then is exchanged at `token_host`+`token_path`.
|
||||
PkceLocalhost {
|
||||
redirect_port: u16,
|
||||
redirect_path: &'static str,
|
||||
},
|
||||
/// GitHub Copilot two-stage flow (github-copilot): an RFC-8628 device flow
|
||||
/// on `auth_host` (github.com) mints the DURABLE GitHub token, which is then
|
||||
/// exchanged at `copilot_exchange` for the SHORT-LIVED copilot session
|
||||
@@ -167,6 +171,11 @@ pub struct OAuthConfig {
|
||||
/// A non-standard extra form field sent ONLY on the device-authorization
|
||||
/// request (e.g. `("referrer", "kigi")`). `None` = no extra field.
|
||||
pub extra_device_field: Option<(&'static str, &'static str)>,
|
||||
/// Extra query params appended ONLY to the `PkceLocalhost` browser authorize
|
||||
/// URL (openai-codex's `id_token_add_organizations`, `codex_cli_simplified_
|
||||
/// flow`, `originator`). Empty for every other config, so their authorize
|
||||
/// URLs stay byte-identical.
|
||||
pub authorize_extra: &'static [(&'static str, &'static str)],
|
||||
/// Interactive login mechanism (device-code vs PKCE-localhost).
|
||||
pub flow: OAuthFlow,
|
||||
/// Body encoding the token endpoint expects (form vs JSON).
|
||||
@@ -178,6 +187,13 @@ pub struct OAuthConfig {
|
||||
/// "refresh". `None` for the standard flows (xai-grok, claude-pro-max),
|
||||
/// whose refresh is a plain `refresh_token` grant against `token_host`.
|
||||
pub copilot_exchange: Option<(&'static str, &'static str)>,
|
||||
/// The access token MUST carry a `chatgpt_account_id` claim (openai-codex):
|
||||
/// it becomes the `chatgpt-account-id` inference header, so a token without
|
||||
/// it cannot authorize a request. Login AND every refresh fail fast when the
|
||||
/// claim is absent. `false` everywhere else — this is an explicit provider
|
||||
/// fact, NEVER inferred from the token-body encoding (a plain form-encoded
|
||||
/// token endpoint is the OAuth norm and must not inherit this requirement).
|
||||
pub requires_chatgpt_account_id: bool,
|
||||
}
|
||||
|
||||
/// xAI / Grok subscription device-code OAuth (ported from Pi
|
||||
@@ -191,9 +207,11 @@ pub const XAI_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
|
||||
scope: "openid profile email offline_access grok-cli:access api:access",
|
||||
scope_key: "oauth/xai",
|
||||
extra_device_field: Some(("referrer", "kigi")),
|
||||
authorize_extra: &[],
|
||||
flow: OAuthFlow::DeviceCode,
|
||||
token_body: OAuthTokenBody::Form,
|
||||
copilot_exchange: None,
|
||||
requires_chatgpt_account_id: false,
|
||||
};
|
||||
|
||||
/// Base-URL override for the Claude Pro/Max OAuth channel (dev/test escape
|
||||
@@ -213,11 +231,14 @@ pub const CLAUDE_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
|
||||
scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
|
||||
scope_key: "oauth/claude-pro-max",
|
||||
extra_device_field: None,
|
||||
authorize_extra: &[],
|
||||
flow: OAuthFlow::PkceLocalhost {
|
||||
redirect_port: 53692,
|
||||
redirect_path: "/callback",
|
||||
},
|
||||
token_body: OAuthTokenBody::Json,
|
||||
copilot_exchange: None,
|
||||
requires_chatgpt_account_id: false,
|
||||
};
|
||||
|
||||
/// Base-URL override for the GitHub Copilot inference/listing channel
|
||||
@@ -240,9 +261,49 @@ pub const COPILOT_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
|
||||
scope: "read:user",
|
||||
scope_key: "oauth/github-copilot",
|
||||
extra_device_field: None,
|
||||
authorize_extra: &[],
|
||||
flow: OAuthFlow::GithubDeviceCopilot,
|
||||
token_body: OAuthTokenBody::GithubCopilotExchange,
|
||||
copilot_exchange: Some(("https://api.github.com", "/copilot_internal/v2/token")),
|
||||
requires_chatgpt_account_id: false,
|
||||
};
|
||||
|
||||
/// Base-URL override for the ChatGPT/Codex OAuth inference channel (dev/test
|
||||
/// escape hatch). Production defaults to the ChatGPT Codex backend
|
||||
/// `https://chatgpt.com/backend-api/codex`; Kigi's Responses path posts to
|
||||
/// `{base}/responses`.
|
||||
pub const CODEX_BASE_URL_ENV: &str = "KIGI_CODEX_BASE_URL";
|
||||
|
||||
/// ChatGPT/Codex subscription OAuth (authorization-code + PKCE S256, loopback
|
||||
/// callback on port 1455 path `/auth/callback`, FORM token body). Authoritative
|
||||
/// constants from the official Codex CLI + Pi `earendil-works/pi`
|
||||
/// `auth/oauth/openai-codex.ts`: authorize + token host `https://auth.openai.com`,
|
||||
/// the `codex_cli_simplified_flow` login client id, and the three authorize-only
|
||||
/// extra params. Unlike claude the `state` is fresh-random (NOT the verifier),
|
||||
/// and refresh is a plain `refresh_token` FORM grant (the generic device
|
||||
/// refresher's `Form` path). The minted `access_token` is a JWT carrying the
|
||||
/// `chatgpt_account_id` claim consumed at inference time.
|
||||
pub const CODEX_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
|
||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
auth_host: "https://auth.openai.com",
|
||||
device_path: "/oauth/authorize",
|
||||
token_host: "https://auth.openai.com",
|
||||
token_path: "/oauth/token",
|
||||
scope: "openid profile email offline_access",
|
||||
scope_key: "oauth/openai-codex",
|
||||
extra_device_field: None,
|
||||
authorize_extra: &[
|
||||
("id_token_add_organizations", "true"),
|
||||
("codex_cli_simplified_flow", "true"),
|
||||
("originator", "codex_cli_rs"),
|
||||
],
|
||||
flow: OAuthFlow::PkceLocalhost {
|
||||
redirect_port: 1455,
|
||||
redirect_path: "/auth/callback",
|
||||
},
|
||||
token_body: OAuthTokenBody::Form,
|
||||
copilot_exchange: None,
|
||||
requires_chatgpt_account_id: true,
|
||||
};
|
||||
|
||||
/// The generic device-code OAuth config for a platform, or `None` for API-key
|
||||
@@ -1184,6 +1245,41 @@ const GITHUB_COPILOT_SPEC: PlatformSpec = PlatformSpec {
|
||||
strip_listing_id_prefix: None,
|
||||
};
|
||||
|
||||
const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec {
|
||||
id: "openai-codex",
|
||||
display_name: "ChatGPT (Codex)",
|
||||
// Kigi's Responses path posts to `{base}/responses`; the codex backend
|
||||
// serves it at `.../codex/responses`, so the base carries the `/codex` tail.
|
||||
base_url: BaseUrlSource::EnvOr {
|
||||
env: CODEX_BASE_URL_ENV,
|
||||
default: "https://chatgpt.com/backend-api/codex",
|
||||
},
|
||||
uses_oauth: true,
|
||||
oauth: Some(&CODEX_OAUTH_CONFIG),
|
||||
allowed_model_prefixes: None,
|
||||
// OAuth channel: no API key envs (the PKCE session is the bearer).
|
||||
api_key_envs: &[],
|
||||
vendor: "OpenAI",
|
||||
console_host: Some("chatgpt.com"),
|
||||
login_label: Some("ChatGPT Plus/Pro (Codex)"),
|
||||
// HARDCODED catalog (see `hardcoded_catalog`): NOT enriched from models.dev
|
||||
// and NOT live-fetched — OpenAI exposes no stable public models endpoint for
|
||||
// this backend.
|
||||
models_dev_id: None,
|
||||
// The catalog is compiled-in and already carries context/thinking metadata,
|
||||
// so enrichment (and its network refresh) is skipped entirely.
|
||||
wire_serves_metadata: true,
|
||||
wire_api: PlatformWireApi::Responses,
|
||||
// Unused: the catalog does NOT come from a live `/models` listing (the
|
||||
// fetch path short-circuits to `hardcoded_catalog`).
|
||||
listing: ListingDialect::OpenAi,
|
||||
chat_compat: PlatformChatCompat::Passthrough,
|
||||
key_header: PlatformKeyHeader::Bearer,
|
||||
restrict_to_enriched: false,
|
||||
key_validation_path: None,
|
||||
strip_listing_id_prefix: None,
|
||||
};
|
||||
|
||||
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
||||
/// dynamic provider registration (PRD F2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
@@ -1246,12 +1342,16 @@ pub enum PlatformId {
|
||||
/// GitHub Copilot subscription via the two-stage device-code OAuth flow
|
||||
/// (ChatCompletions wire reached with the short-lived copilot token).
|
||||
GithubCopilot,
|
||||
/// ChatGPT Plus/Pro subscription via PKCE-localhost OAuth against the
|
||||
/// ChatGPT Codex backend (Responses wire reached with an OAuth bearer +
|
||||
/// the `chatgpt-account-id` JWT claim). HARDCODED catalog, no live listing.
|
||||
OpenaiCodex,
|
||||
}
|
||||
|
||||
impl PlatformId {
|
||||
/// All platforms, in catalog precedence order: the subscription channel
|
||||
/// first so "default model = first list item" favors it when present.
|
||||
pub const ALL: [PlatformId; 28] = [
|
||||
pub const ALL: [PlatformId; 29] = [
|
||||
Self::KimiCode,
|
||||
Self::MoonshotCn,
|
||||
Self::MoonshotAi,
|
||||
@@ -1280,6 +1380,7 @@ impl PlatformId {
|
||||
Self::XaiGrok,
|
||||
Self::ClaudeProMax,
|
||||
Self::GithubCopilot,
|
||||
Self::OpenaiCodex,
|
||||
];
|
||||
|
||||
/// The registry row backing this platform (single source of per-platform
|
||||
@@ -1314,6 +1415,7 @@ impl PlatformId {
|
||||
Self::XaiGrok => &XAI_GROK_SPEC,
|
||||
Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC,
|
||||
Self::GithubCopilot => &GITHUB_COPILOT_SPEC,
|
||||
Self::OpenaiCodex => &OPENAI_CODEX_SPEC,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1450,6 +1552,88 @@ impl PlatformId {
|
||||
pub fn sends_copilot_editor_headers(self) -> bool {
|
||||
matches!(self, Self::GithubCopilot)
|
||||
}
|
||||
|
||||
/// True ONLY for `openai-codex`: its `/responses` inference request must
|
||||
/// carry the Codex identity headers (`chatgpt-account-id` from the bearer
|
||||
/// JWT, `originator: codex_cli_rs`, `OpenAI-Beta: responses=experimental`, a
|
||||
/// codex `User-Agent`). The sampler gates these on this predicate, so the
|
||||
/// API-key `openai` Responses requests stay byte-identical.
|
||||
pub fn sends_codex_responses_headers(self) -> bool {
|
||||
matches!(self, Self::OpenaiCodex)
|
||||
}
|
||||
|
||||
/// The compiled-in catalog for a platform that serves NO live `/models`
|
||||
/// listing (openai-codex), or `None` when the catalog comes from the wire.
|
||||
///
|
||||
/// openai-codex's 4 models are HARDCODED (read from the official Codex CLI's
|
||||
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
|
||||
/// set) because OpenAI exposes no stable public models endpoint for the
|
||||
/// ChatGPT Codex backend. Each entry carries context window + per-model
|
||||
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`/`ultra`
|
||||
/// tiers), so the fetch path maps them through the SAME
|
||||
/// `platform_wire_model_to_entry` output as a live listing — no new type.
|
||||
pub fn hardcoded_catalog(self) -> Option<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
|
||||
@@ -2288,7 +2472,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
cfg.flow,
|
||||
OAuthFlow::PkceLocalhost {
|
||||
redirect_port: 53692
|
||||
redirect_port: 53692,
|
||||
redirect_path: "/callback",
|
||||
}
|
||||
);
|
||||
assert_eq!(cfg.token_body, OAuthTokenBody::Json);
|
||||
@@ -2379,6 +2564,176 @@ mod tests {
|
||||
assert_eq!(g.base_url(), "https://mock.copilot");
|
||||
}
|
||||
|
||||
/// openai-codex is the ChatGPT/Codex PKCE-localhost OAuth platform: it
|
||||
/// carries a `PkceLocalhost{1455, "/auth/callback"}` / FORM `OAuthConfig`
|
||||
/// with the 3 authorize-extra params, speaks the Responses wire with a Bearer
|
||||
/// OAuth token + the codex-headers gate, and is NOT models.dev-enriched (its
|
||||
/// catalog is hardcoded).
|
||||
#[test]
|
||||
fn openai_codex_is_a_pkce_responses_oauth_platform() {
|
||||
let c = PlatformId::OpenaiCodex;
|
||||
assert_eq!(c.as_str(), "openai-codex");
|
||||
assert!(c.uses_oauth());
|
||||
assert!(
|
||||
c.sends_codex_responses_headers(),
|
||||
"openai-codex must gate the Codex identity headers"
|
||||
);
|
||||
// Every OTHER platform must NOT send the codex headers (regression).
|
||||
for other in PlatformId::ALL {
|
||||
if other != PlatformId::OpenaiCodex {
|
||||
assert!(
|
||||
!other.sends_codex_responses_headers(),
|
||||
"{} must not send the codex responses headers",
|
||||
other.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
let cfg = c.oauth().expect("openai-codex carries a PKCE OAuthConfig");
|
||||
assert_eq!(cfg, &CODEX_OAUTH_CONFIG);
|
||||
assert_eq!(cfg.client_id, "app_EMoamEEZ73f0CkXaXp7hrann");
|
||||
assert_eq!(cfg.auth_host, "https://auth.openai.com");
|
||||
assert_eq!(cfg.token_host, "https://auth.openai.com");
|
||||
assert_eq!(cfg.device_path, "/oauth/authorize");
|
||||
assert_eq!(cfg.token_path, "/oauth/token");
|
||||
assert_eq!(cfg.scope, "openid profile email offline_access");
|
||||
assert_eq!(cfg.scope_key, "oauth/openai-codex");
|
||||
assert_eq!(
|
||||
cfg.flow,
|
||||
OAuthFlow::PkceLocalhost {
|
||||
redirect_port: 1455,
|
||||
redirect_path: "/auth/callback",
|
||||
}
|
||||
);
|
||||
// FORM token body (refresh routes through the generic device refresher).
|
||||
assert_eq!(cfg.token_body, OAuthTokenBody::Form);
|
||||
assert_eq!(cfg.copilot_exchange, None);
|
||||
// The 3 authorize-only extra params; claude/xai/copilot carry none.
|
||||
assert_eq!(
|
||||
cfg.authorize_extra,
|
||||
&[
|
||||
("id_token_add_organizations", "true"),
|
||||
("codex_cli_simplified_flow", "true"),
|
||||
("originator", "codex_cli_rs"),
|
||||
]
|
||||
);
|
||||
assert_eq!(CLAUDE_OAUTH_CONFIG.authorize_extra, &[] as &[(&str, &str)]);
|
||||
assert_eq!(XAI_OAUTH_CONFIG.authorize_extra, &[] as &[(&str, &str)]);
|
||||
assert_eq!(COPILOT_OAUTH_CONFIG.authorize_extra, &[] as &[(&str, &str)]);
|
||||
// The chatgpt_account_id requirement is an EXPLICIT per-provider fact,
|
||||
// never inferred from the token-body encoding — a future form-encoded
|
||||
// PKCE provider must not inherit ChatGPT's account-id gate. Enforced at
|
||||
// COMPILE time: adding a provider that flips this fails the build.
|
||||
const {
|
||||
assert!(CODEX_OAUTH_CONFIG.requires_chatgpt_account_id);
|
||||
assert!(!CLAUDE_OAUTH_CONFIG.requires_chatgpt_account_id);
|
||||
assert!(!XAI_OAUTH_CONFIG.requires_chatgpt_account_id);
|
||||
assert!(!COPILOT_OAUTH_CONFIG.requires_chatgpt_account_id);
|
||||
}
|
||||
assert_eq!(
|
||||
oauth_config_for_scope_key("oauth/openai-codex"),
|
||||
Some(&CODEX_OAUTH_CONFIG)
|
||||
);
|
||||
// Responses wire, Bearer, hardcoded (no models.dev id), own base.
|
||||
assert_eq!(c.models_dev_id(), None);
|
||||
assert_eq!(c.wire_api(), PlatformWireApi::Responses);
|
||||
assert_eq!(c.key_header(), PlatformKeyHeader::Bearer);
|
||||
assert_eq!(c.api_key_env_names(), &[] as &[&str]);
|
||||
assert_eq!(c.managed_model_key("gpt-5.5"), "openai-codex/gpt-5.5");
|
||||
assert_eq!(
|
||||
c.base_url(),
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
"default codex base carries the /codex tail (→ /codex/responses)"
|
||||
);
|
||||
let _guard = kigi_env::EnvVarGuard::set(CODEX_BASE_URL_ENV, "https://mock.codex/codex");
|
||||
assert_eq!(c.base_url(), "https://mock.codex/codex");
|
||||
}
|
||||
|
||||
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
|
||||
/// models, keyed by slug, ctx 272000, each exposing its exact supported
|
||||
/// efforts (incl. the codex-only `xhigh`/`max`/`ultra` tiers). The
|
||||
/// list-but-broken / hidden models are absent. Every other platform serves
|
||||
/// NO hardcoded catalog (its models come from the live wire).
|
||||
#[test]
|
||||
fn openai_codex_hardcoded_catalog_is_the_four_supported_models() {
|
||||
let catalog = PlatformId::OpenaiCodex
|
||||
.hardcoded_catalog()
|
||||
.expect("openai-codex serves a hardcoded catalog");
|
||||
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
|
||||
"exactly the 4 visibility=list AND supported_in_api=true models"
|
||||
);
|
||||
// Excluded: list-but-not-served + hidden models never appear.
|
||||
for absent in [
|
||||
"gpt-5.3-codex-spark",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"codex-auto-review",
|
||||
] {
|
||||
assert!(
|
||||
!ids.contains(&absent),
|
||||
"{absent} must be excluded from the hardcoded catalog"
|
||||
);
|
||||
}
|
||||
for m in &catalog {
|
||||
assert_eq!(m.context_length, 272_000, "{} ctx", m.id);
|
||||
assert!(m.supports_reasoning && m.supports_image_in, "{} caps", m.id);
|
||||
let caps = m.capabilities();
|
||||
assert!(caps.contains(&ModelCapability::Thinking));
|
||||
assert!(caps.contains(&ModelCapability::ImageIn));
|
||||
}
|
||||
// Per-model efforts (the crux of "their thinking method").
|
||||
let efforts = |slug: &str| -> Vec<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,
|
||||
/// selectable, tool-calling models — dropping the claude-4.x/5.x (messages)
|
||||
/// and gpt-5/oswe/mai- (responses-only) ids AND the disabled / picker-off /
|
||||
@@ -2464,9 +2819,10 @@ mod tests {
|
||||
PlatformId::XaiGrok => 25,
|
||||
PlatformId::ClaudeProMax => 26,
|
||||
PlatformId::GithubCopilot => 27,
|
||||
PlatformId::OpenaiCodex => 28,
|
||||
}
|
||||
}
|
||||
const VARIANT_COUNT: usize = 28; // update together with `ordinal`
|
||||
const VARIANT_COUNT: usize = 29; // update together with `ordinal`
|
||||
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
|
||||
@@ -91,6 +91,7 @@ mod tests {
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
|
||||
@@ -304,6 +304,9 @@ struct ClientDefaults {
|
||||
doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>,
|
||||
/// Claude Pro/Max OAuth Messages adaptation (see [`SamplerConfig`]).
|
||||
anthropic_oauth: bool,
|
||||
/// ChatGPT/Codex Responses adaptation (see [`SamplerConfig`]). Gates the
|
||||
/// per-request `chatgpt-account-id` header derived from the bearer JWT.
|
||||
openai_codex: bool,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -479,6 +482,24 @@ impl SamplingClient {
|
||||
);
|
||||
}
|
||||
|
||||
// ChatGPT/Codex Responses identity headers (openai-codex only). The
|
||||
// Codex backend authorizes the OAuth bearer AND validates the Codex
|
||||
// client identity. The static pieces (`originator`, `OpenAI-Beta`) ride
|
||||
// every request; `chatgpt-account-id` is dynamic (derived per-request
|
||||
// from the bearer JWT in `post()`), and `User-Agent` is set in the UA
|
||||
// block below. Gated on `openai_codex` so API-key `openai` Responses
|
||||
// requests stay byte-identical (`store: false` is the shared default).
|
||||
if config.openai_codex {
|
||||
headers.insert(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static(kigi_sampling_types::CODEX_ORIGINATOR),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("openai-beta"),
|
||||
HeaderValue::from_static(kigi_sampling_types::CODEX_OPENAI_BETA),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply all extra headers verbatim. This is the single
|
||||
// injection point for proxy-auth headers and any other URL- or
|
||||
// environment-specific headers the session decides to set.
|
||||
@@ -503,6 +524,8 @@ impl SamplingClient {
|
||||
kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string()
|
||||
} else if config.github_copilot {
|
||||
kigi_sampling_types::COPILOT_USER_AGENT.to_string()
|
||||
} else if config.openai_codex {
|
||||
kigi_sampling_types::CODEX_USER_AGENT.to_string()
|
||||
} else {
|
||||
match config.origin_client.as_ref() {
|
||||
Some(origin) => user_agent_string_for(origin),
|
||||
@@ -551,6 +574,7 @@ impl SamplingClient {
|
||||
stream_tool_calls: config.stream_tool_calls,
|
||||
doom_loop_recovery: config.doom_loop_recovery,
|
||||
anthropic_oauth: config.anthropic_oauth,
|
||||
openai_codex: config.openai_codex,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
@@ -590,6 +614,23 @@ impl SamplingClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ChatGPT/Codex `chatgpt-account-id` (openai-codex only): derive it
|
||||
// STATELESSLY from the bearer that will actually ride this request (the
|
||||
// resolver-fresh one just set, or the construction-time bearer in
|
||||
// `default_headers`) by decoding its JWT claim. A refreshed token still
|
||||
// carries the claim, so there is no persisted account-id field.
|
||||
// openai-codex-gated → API-key `openai` never gets this header.
|
||||
// SECURITY: the bearer and account id are never logged here.
|
||||
if self.defaults.openai_codex
|
||||
&& let Some(bearer) = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
&& let Some(account_id) = kigi_sampling_types::chatgpt_account_id_from_jwt(bearer)
|
||||
&& let Ok(v) = HeaderValue::from_str(&account_id)
|
||||
{
|
||||
headers.insert(HeaderName::from_static("chatgpt-account-id"), v);
|
||||
}
|
||||
{
|
||||
let auth_prefix = headers
|
||||
.get(AUTHORIZATION)
|
||||
@@ -699,6 +740,9 @@ impl SamplingClient {
|
||||
|| lower.contains("apikey")
|
||||
|| lower.contains("token")
|
||||
|| lower.contains("secret")
|
||||
// `chatgpt-account-id` (Codex) and any other account identifier: a
|
||||
// stable per-user id that must never reach a log.
|
||||
|| lower.contains("account-id")
|
||||
}
|
||||
|
||||
/// Format a single header for error messages, redacting sensitive values.
|
||||
@@ -1990,6 +2034,7 @@ mod tests {
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
@@ -2148,6 +2193,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// SECURITY REGRESSION: `chatgpt-account-id` (the Codex per-user account
|
||||
/// identifier) must be redacted by the header loggers. It is not a token, so
|
||||
/// the substring list has to name it explicitly — without this, one debug
|
||||
/// request log would write the user's stable ChatGPT account id to disk.
|
||||
#[test]
|
||||
fn account_id_headers_are_redacted_from_logs() {
|
||||
assert!(
|
||||
SamplingClient::is_sensitive_header("chatgpt-account-id"),
|
||||
"chatgpt-account-id must be treated as sensitive"
|
||||
);
|
||||
assert!(
|
||||
SamplingClient::is_sensitive_header("ChatGPT-Account-Id"),
|
||||
"redaction is case-insensitive"
|
||||
);
|
||||
let rendered = SamplingClient::format_header("chatgpt-account-id", "acct-abc-123");
|
||||
assert!(
|
||||
rendered.contains("[REDACTED]") && !rendered.contains("acct-abc-123"),
|
||||
"the account id value must never appear verbatim, got {rendered:?}"
|
||||
);
|
||||
// Sanity: an ordinary header is still shown (redaction stays targeted).
|
||||
assert!(!SamplingClient::is_sensitive_header("content-type"));
|
||||
}
|
||||
|
||||
/// REGRESSION: a plain ChatCompletions client (github-copilot OFF, standing
|
||||
/// in for groq) carries NONE of the Copilot editor headers and keeps the
|
||||
/// kigi User-Agent — every other ChatCompletions provider stays untouched.
|
||||
@@ -2181,6 +2249,88 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// A JWT whose payload carries `["https://api.openai.com/auth"]
|
||||
// ["chatgpt_account_id"] = "acct-test-42"` (alg=none; signature is cosmetic
|
||||
// — the extractor only base64url-decodes the payload segment).
|
||||
const CODEX_TEST_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC10ZXN0LTQyIn0sInN1YiI6InVzZXItMSJ9.sig";
|
||||
|
||||
/// openai-codex Responses client (`openai_codex = true`): the STATIC codex
|
||||
/// identity headers (`originator`, `OpenAI-Beta`, codex `User-Agent`) ride
|
||||
/// `default_headers`, and a built `post()` request derives `chatgpt-account-
|
||||
/// id` from the bearer JWT. The Responses body default keeps `store: false`.
|
||||
#[test]
|
||||
fn openai_codex_client_sends_codex_identity_headers() {
|
||||
let mut config = minimal_config();
|
||||
config.api_backend = ApiBackend::Responses;
|
||||
config.openai_codex = true;
|
||||
config.api_key = Some(CODEX_TEST_JWT.to_string());
|
||||
let client = SamplingClient::new(config).expect("client builds");
|
||||
let h = &client.default_headers;
|
||||
assert_eq!(
|
||||
h.get("originator").and_then(|v| v.to_str().ok()),
|
||||
Some(kigi_sampling_types::CODEX_ORIGINATOR),
|
||||
"codex must send originator: codex_cli_rs"
|
||||
);
|
||||
assert_eq!(
|
||||
h.get("openai-beta").and_then(|v| v.to_str().ok()),
|
||||
Some(kigi_sampling_types::CODEX_OPENAI_BETA),
|
||||
"codex must opt into OpenAI-Beta: responses=experimental"
|
||||
);
|
||||
assert_eq!(
|
||||
h.get(USER_AGENT).and_then(|v| v.to_str().ok()),
|
||||
Some(kigi_sampling_types::CODEX_USER_AGENT),
|
||||
"codex presents the codex User-Agent"
|
||||
);
|
||||
// The account id is derived PER REQUEST from the bearer JWT in post().
|
||||
let req = client
|
||||
.post("https://chatgpt.com/backend-api/codex/responses")
|
||||
.build()
|
||||
.expect("build request");
|
||||
assert_eq!(
|
||||
req.headers()
|
||||
.get("chatgpt-account-id")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("acct-test-42"),
|
||||
"chatgpt-account-id must be decoded from the bearer JWT claim"
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION: an API-key `openai` Responses client (`openai_codex = false`)
|
||||
/// carries NONE of the Codex identity headers — not the static ones and not
|
||||
/// the per-request `chatgpt-account-id` — so its request stays byte-identical.
|
||||
#[test]
|
||||
fn api_key_openai_responses_client_has_no_codex_headers() {
|
||||
let mut config = minimal_config();
|
||||
config.api_backend = ApiBackend::Responses;
|
||||
// openai_codex stays false (as it is for API-key openai). Even with a
|
||||
// JWT-shaped key, no account-id header is derived.
|
||||
config.api_key = Some(CODEX_TEST_JWT.to_string());
|
||||
let client = SamplingClient::new(config).expect("client builds");
|
||||
let h = &client.default_headers;
|
||||
assert!(
|
||||
h.get("originator").is_none(),
|
||||
"openai must NOT send originator"
|
||||
);
|
||||
assert!(
|
||||
h.get("openai-beta").is_none(),
|
||||
"openai must NOT send the codex OpenAI-Beta"
|
||||
);
|
||||
assert!(
|
||||
h.get(USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|ua| ua.starts_with("kigi/")),
|
||||
"API-key openai keeps the kigi User-Agent"
|
||||
);
|
||||
let req = client
|
||||
.post("https://api.openai.com/v1/responses")
|
||||
.build()
|
||||
.expect("build request");
|
||||
assert!(
|
||||
req.headers().get("chatgpt-account-id").is_none(),
|
||||
"API-key openai must NOT send chatgpt-account-id (regression)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The system-prompt prefix is prepended as a distinct leading `text`
|
||||
/// block for each `system` shape (absent / string / blocks), preserving the
|
||||
/// caller's prompt, and is idempotent (not stamped twice).
|
||||
|
||||
@@ -70,6 +70,14 @@ pub struct SamplerConfig {
|
||||
/// byte-identical.
|
||||
#[serde(default)]
|
||||
pub github_copilot: bool,
|
||||
/// ChatGPT/Codex Responses adaptation (openai-codex only). When true the
|
||||
/// `/codex/responses` request carries the Codex identity headers
|
||||
/// (`chatgpt-account-id` derived per-request from the bearer JWT,
|
||||
/// `originator: codex_cli_rs`, `OpenAI-Beta: responses=experimental`, a codex
|
||||
/// `User-Agent`). Gated so the API-key `openai` Responses requests stay
|
||||
/// byte-identical (`store: false` is already the shared Responses default).
|
||||
#[serde(default)]
|
||||
pub openai_codex: bool,
|
||||
/// Extra request headers applied verbatim. The sampler never inspects
|
||||
/// the URL to derive headers; callers (the session) inject proxy auth
|
||||
/// and other access headers here before constructing the config.
|
||||
@@ -161,6 +169,7 @@ impl Default for SamplerConfig {
|
||||
auth_scheme: AuthScheme::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 0,
|
||||
force_http1: false,
|
||||
|
||||
@@ -80,6 +80,7 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig {
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 128_000,
|
||||
|
||||
@@ -7,6 +7,7 @@ description = "Pure data types for the xAI sampling / chat-completion API layer"
|
||||
|
||||
[dependencies]
|
||||
async-openai = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
indexmap = { workspace = true, features = ["serde"] }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -913,6 +913,11 @@ pub enum ReasoningEffort {
|
||||
/// Messages both accept `xhigh` AND `max` as separate levels in 2026;
|
||||
/// the Kimi wire spells its top tier `max` with no `xhigh`).
|
||||
Max,
|
||||
/// Codex-only top tier above `max` (the ChatGPT Codex backend exposes an
|
||||
/// `ultra` reasoning effort on its flagship models). Reachable ONLY via a
|
||||
/// model's server-declared effort menu (openai-codex); no built-in fallback
|
||||
/// menu offers it, so other providers never emit it.
|
||||
Ultra,
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
@@ -941,12 +946,15 @@ impl ReasoningEffort {
|
||||
Self::High => "high",
|
||||
Self::Xhigh => "xhigh",
|
||||
Self::Max => "max",
|
||||
Self::Ultra => "ultra",
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic Messages API effort string; `None` for unsupported variants.
|
||||
/// `xhigh` and `max` are distinct levels on the 2026 Messages API (both
|
||||
/// appear in `GET /v1/models` `capabilities.effort`).
|
||||
/// appear in `GET /v1/models` `capabilities.effort`). `ultra` is codex-only
|
||||
/// and never selected on an Anthropic model, but maps to its own string for
|
||||
/// completeness (the Responses path writes effort via `as_str`, not this).
|
||||
pub fn to_messages_api(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None | Self::Minimal => None,
|
||||
@@ -955,6 +963,7 @@ impl ReasoningEffort {
|
||||
Self::High => Some("high"),
|
||||
Self::Xhigh => Some("xhigh"),
|
||||
Self::Max => Some("max"),
|
||||
Self::Ultra => Some("ultra"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,8 +986,9 @@ impl std::str::FromStr for ReasoningEffort {
|
||||
"high" => Ok(Self::High),
|
||||
"xhigh" => Ok(Self::Xhigh),
|
||||
"max" => Ok(Self::Max),
|
||||
"ultra" => Ok(Self::Ultra),
|
||||
_ => Err(format!(
|
||||
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max)"
|
||||
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max, ultra)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -1098,6 +1108,59 @@ pub const COPILOT_API_VERSION: &str = "2026-06-01";
|
||||
/// `X-Initiator` value — sent ONLY on inference (`user`, per the spec).
|
||||
pub const COPILOT_INITIATOR: &str = "user";
|
||||
|
||||
// ── ChatGPT/Codex (openai-codex) OAuth-inference headers ─────────────────────
|
||||
// The ChatGPT Codex backend authorizes an OAuth bearer AND validates the Codex
|
||||
// client identity. These ride the `/codex/responses` inference request ONLY.
|
||||
// openai-codex-GATED: no other Responses provider (API-key `openai`) sends them,
|
||||
// so their requests stay byte-identical. Values are non-secret wire constants
|
||||
// (ported from the official Codex CLI + Pi `api/openai-codex-responses.ts`).
|
||||
|
||||
/// `originator` header identifying the Codex CLI client (matches the authorize
|
||||
/// `originator` param).
|
||||
pub const CODEX_ORIGINATOR: &str = "codex_cli_rs";
|
||||
/// `OpenAI-Beta` opt-in the Codex Responses endpoint requires.
|
||||
pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
|
||||
/// `User-Agent` presented on the Codex path (overrides the default kigi UA,
|
||||
/// openai-codex-gated). The Codex backend does not strictly validate the UA
|
||||
/// string (Pi ships its own and it works), so this is a stable best-effort
|
||||
/// identity, not a pinned build.
|
||||
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.104.0";
|
||||
/// JWT payload claim namespace carrying the ChatGPT account id.
|
||||
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
|
||||
|
||||
/// Extract the `chatgpt_account_id` from a Codex OAuth access token (a JWT):
|
||||
/// base64url-decode the payload segment and read
|
||||
/// `["https://api.openai.com/auth"]["chatgpt_account_id"]`. Returns `None` when
|
||||
/// the token is not a well-formed JWT or the claim is missing/empty.
|
||||
///
|
||||
/// Used BOTH at login (fail-fast: a token without the claim is useless) and at
|
||||
/// inference (the header is derived STATELESSLY from the current bearer, so a
|
||||
/// refreshed token — which still carries the claim — needs no persisted field).
|
||||
///
|
||||
/// SECURITY: the token, its payload, and the returned account id are NEVER
|
||||
/// logged by this function or its callers.
|
||||
pub fn chatgpt_account_id_from_jwt(token: &str) -> Option<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
|
||||
/// how thinking rides an OpenAI-compatible body: Kimi wants
|
||||
/// `thinking:{type,effort}`, DeepSeek wants
|
||||
@@ -1633,6 +1696,86 @@ mod tests {
|
||||
assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max"));
|
||||
}
|
||||
|
||||
/// The codex-only `ultra` tier parses, serializes, and patches onto a
|
||||
/// Responses body as `reasoning.effort = "ultra"` (the crux of surfacing a
|
||||
/// codex model's full thinking menu). It is a DISTINCT level above `max`.
|
||||
#[test]
|
||||
fn reasoning_effort_ultra_is_a_distinct_codex_tier() {
|
||||
assert_eq!(
|
||||
"ultra".parse::<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]
|
||||
fn parse_canonical_effort_token_helper() {
|
||||
assert_eq!(
|
||||
@@ -1797,7 +1940,8 @@ mod tests {
|
||||
);
|
||||
let bad_type = as_map(serde_json::json!({"reasoningEffort": 3}));
|
||||
assert_eq!(parse_reasoning_effort_meta(Some(&bad_type)), None);
|
||||
let unknown = as_map(serde_json::json!({"reasoningEffort": "ULTRA"}));
|
||||
// `ultra` is now a real codex tier; a genuinely-unknown token still None.
|
||||
let unknown = as_map(serde_json::json!({"reasoningEffort": "MEGA"}));
|
||||
assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -605,6 +605,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
ids[kimi_pos + 4],
|
||||
"openai-codex",
|
||||
"openai-codex is the next interactive OAuth login, after github-copilot"
|
||||
);
|
||||
assert_eq!(
|
||||
ids[kimi_pos + 5],
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
"the api-key rows follow the generic oauth logins"
|
||||
);
|
||||
@@ -756,6 +761,7 @@ mod tests {
|
||||
"xai-grok",
|
||||
"claude-pro-max",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
@@ -807,6 +813,7 @@ mod tests {
|
||||
"xai-grok",
|
||||
"claude-pro-max",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
@@ -851,6 +858,7 @@ mod tests {
|
||||
"xai-grok",
|
||||
"claude-pro-max",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
@@ -898,6 +906,7 @@ mod tests {
|
||||
"xai-grok",
|
||||
"claude-pro-max",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
|
||||
@@ -4099,6 +4099,15 @@ pub fn sampling_config_for_model(
|
||||
.as_deref()
|
||||
.and_then(kigi_models::parse_managed_model_key)
|
||||
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers());
|
||||
// ChatGPT/Codex Responses identity headers: a managed key whose platform is
|
||||
// openai-codex drives the codex headers (`chatgpt-account-id` + originator +
|
||||
// OpenAI-Beta) in the sampler. Gated here so API-key `openai` Responses
|
||||
// requests stay byte-identical.
|
||||
let openai_codex = info
|
||||
.id
|
||||
.as_deref()
|
||||
.and_then(kigi_models::parse_managed_model_key)
|
||||
.is_some_and(|(platform, _)| platform.sends_codex_responses_headers());
|
||||
SamplerConfig {
|
||||
api_key: credentials.api_key,
|
||||
model: model_name,
|
||||
@@ -4110,6 +4119,7 @@ pub fn sampling_config_for_model(
|
||||
auth_scheme: credentials.auth_scheme,
|
||||
anthropic_oauth,
|
||||
github_copilot,
|
||||
openai_codex,
|
||||
chat_compat,
|
||||
extra_headers,
|
||||
context_window: info.context_window.get(),
|
||||
|
||||
@@ -334,6 +334,19 @@ fn fetch_one_platform_models(
|
||||
bearer: &str,
|
||||
enrichment: &kigi_models::enrichment::EnrichmentCatalog,
|
||||
) -> Result<(Vec<crate::agent::config::ModelEntryConfig>, Option<String>), BackendError> {
|
||||
// A platform that serves NO live `/models` listing (openai-codex) delivers a
|
||||
// HARDCODED catalog: short-circuit BEFORE any HTTP, mapping the compiled-in
|
||||
// `WireModel`s through the SAME `platform_wire_model_to_entry` output a live
|
||||
// listing produces (context window + per-model reasoning efforts). The
|
||||
// bearer is unused here (login gates availability; no request is made).
|
||||
if let Some(wire_models) = platform.hardcoded_catalog() {
|
||||
let base_url = platform_fetch_base(platform, endpoints);
|
||||
let models = wire_models
|
||||
.into_iter()
|
||||
.map(|wire| platform_wire_model_to_entry(platform, wire, &base_url))
|
||||
.collect();
|
||||
return Ok((models, None));
|
||||
}
|
||||
let client = crate::http::shared_blocking_client();
|
||||
let url = match platform.listing() {
|
||||
kigi_models::ListingDialect::OpenAi => platform_models_url(platform, endpoints),
|
||||
@@ -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
|
||||
/// (high/max) produce ChatCompletions entries whose sampler config
|
||||
/// speaks the DeepSeek thinking dialect.
|
||||
|
||||
@@ -60,6 +60,7 @@ fn effort_label(effort: ReasoningEffort) -> String {
|
||||
ReasoningEffort::High => "High",
|
||||
ReasoningEffort::Xhigh => "X-High",
|
||||
ReasoningEffort::Max => "Max",
|
||||
ReasoningEffort::Ultra => "Ultra",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -893,6 +893,11 @@ async fn read_parent_sampling_config(
|
||||
// false, so the other ChatCompletions paths stay byte-identical.
|
||||
let github_copilot = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
|
||||
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers());
|
||||
// ChatGPT/Codex headers inherit from the parent model's platform
|
||||
// (openai-codex → true); every other platform / BYOK → false, so the
|
||||
// API-key openai Responses path stays byte-identical.
|
||||
let openai_codex = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
|
||||
.is_some_and(|(platform, _)| platform.sends_codex_responses_headers());
|
||||
let inherited = kigi_sampler::SamplerConfig {
|
||||
api_key: creds.api_key,
|
||||
base_url: cfg.base_url,
|
||||
@@ -904,6 +909,7 @@ async fn read_parent_sampling_config(
|
||||
auth_scheme,
|
||||
anthropic_oauth,
|
||||
github_copilot,
|
||||
openai_codex,
|
||||
chat_compat: cfg.chat_compat,
|
||||
extra_headers,
|
||||
context_window: cfg.context_window.get(),
|
||||
|
||||
@@ -104,8 +104,18 @@ pub async fn run_oauth_provider_flow(
|
||||
)
|
||||
.await
|
||||
}
|
||||
kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => {
|
||||
run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await
|
||||
kigi_models::OAuthFlow::PkceLocalhost {
|
||||
redirect_port,
|
||||
redirect_path,
|
||||
} => {
|
||||
run_pkce_localhost_login(
|
||||
oauth,
|
||||
redirect_port,
|
||||
redirect_path,
|
||||
auth_manager,
|
||||
&mut channels,
|
||||
)
|
||||
.await
|
||||
}
|
||||
kigi_models::OAuthFlow::GithubDeviceCopilot => {
|
||||
crate::auth::device_code::run_device_code_login_github_copilot(
|
||||
@@ -118,23 +128,40 @@ pub async fn run_oauth_provider_flow(
|
||||
}
|
||||
}
|
||||
|
||||
/// PKCE-localhost login (claude-pro-max): generate PKCE, present the browser
|
||||
/// authorize URL (TUI channel or stderr), open the browser, then await the code
|
||||
/// from EITHER the `127.0.0.1:{redirect_port}` loopback callback OR a manual
|
||||
/// paste (headless fallback). Exchange it at the token endpoint and persist.
|
||||
/// PKCE-localhost login (claude-pro-max JSON, openai-codex FORM): generate PKCE,
|
||||
/// present the browser authorize URL (TUI channel or stderr), open the browser,
|
||||
/// then await the code from EITHER the `127.0.0.1:{redirect_port}{redirect_path}`
|
||||
/// loopback callback OR a manual paste (headless fallback). Exchange it at the
|
||||
/// token endpoint and persist.
|
||||
///
|
||||
/// SECURITY: the verifier / code / tokens are never logged; the loopback binds
|
||||
/// `127.0.0.1` only and validates `state` strictly.
|
||||
/// The `token_body` selects the wire dialect: `Json` is the claude path
|
||||
/// (`state == verifier`, JSON exchange carrying `state`); `Form` is the codex
|
||||
/// path (fresh-random `state`, FORM exchange WITHOUT `state`, then a FAIL-FAST
|
||||
/// check that the minted JWT carries a `chatgpt_account_id` — a token without it
|
||||
/// is useless for inference, so the login bails rather than persisting it).
|
||||
///
|
||||
/// SECURITY: the verifier / code / tokens / JWT / account id are never logged;
|
||||
/// the loopback binds `127.0.0.1` only and validates `state` strictly.
|
||||
async fn run_pkce_localhost_login(
|
||||
oauth: &'static kigi_models::OAuthConfig,
|
||||
redirect_port: u16,
|
||||
redirect_path: &'static str,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
channels: &mut Option<AuthChannels>,
|
||||
) -> anyhow::Result<(KimiAuth, bool)> {
|
||||
use crate::auth::oauth_pkce;
|
||||
|
||||
let pkce = oauth_pkce::generate_pkce();
|
||||
let redirect = oauth_pkce::redirect_uri(redirect_port);
|
||||
// The token-body encoding also selects the PKCE `state` convention: the JSON
|
||||
// dialect is Claude's/Pi's `state == verifier`, while form-encoded endpoints
|
||||
// (the OAuth norm) get an INDEPENDENT random state. A new form provider
|
||||
// inheriting the standard random state is correct by default.
|
||||
let uses_form_exchange = matches!(oauth.token_body, kigi_models::OAuthTokenBody::Form);
|
||||
let pkce = if uses_form_exchange {
|
||||
oauth_pkce::generate_pkce_random_state()
|
||||
} else {
|
||||
oauth_pkce::generate_pkce()
|
||||
};
|
||||
let redirect = oauth_pkce::redirect_uri(redirect_port, redirect_path);
|
||||
let authorize_url = oauth_pkce::build_authorize_url(oauth, &redirect, &pkce);
|
||||
|
||||
let mut chans = channels.take();
|
||||
@@ -148,7 +175,7 @@ async fn run_pkce_localhost_login(
|
||||
crate::auth::device_code::open_browser_detached(&authorize_url).await;
|
||||
} else {
|
||||
eprintln!();
|
||||
eprintln!("To sign in to Claude Pro/Max, open this URL in your browser:");
|
||||
eprintln!("To sign in, open this URL in your browser:");
|
||||
eprintln!();
|
||||
eprintln!(" {authorize_url}");
|
||||
eprintln!();
|
||||
@@ -159,8 +186,24 @@ async fn run_pkce_localhost_login(
|
||||
eprintln!("Waiting for the sign-in to complete...");
|
||||
}
|
||||
|
||||
let code = await_pkce_code(redirect_port, &pkce, chans.as_mut()).await?;
|
||||
let auth = oauth_pkce::exchange_code(oauth, &code, &pkce, &redirect).await?;
|
||||
let code = await_pkce_code(redirect_port, redirect_path, &pkce, chans.as_mut()).await?;
|
||||
let auth = if uses_form_exchange {
|
||||
oauth_pkce::exchange_code_form(oauth, &code, &pkce, &redirect).await?
|
||||
} else {
|
||||
oauth_pkce::exchange_code(oauth, &code, &pkce, &redirect).await?
|
||||
};
|
||||
// FAIL FAST: an access token that yields no chatgpt_account_id cannot
|
||||
// authorize inference (it becomes the `chatgpt-account-id` header) — bail
|
||||
// rather than persist a dead session. Gated on the EXPLICIT provider fact,
|
||||
// never on the token-body encoding.
|
||||
if oauth.requires_chatgpt_account_id
|
||||
&& kigi_sampling_types::chatgpt_account_id_from_jwt(&auth.key).is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"ChatGPT login did not return a usable account id \
|
||||
(the access token is missing the chatgpt_account_id claim)"
|
||||
);
|
||||
}
|
||||
let auth = auth_manager
|
||||
.update(auth)
|
||||
.await
|
||||
@@ -174,6 +217,7 @@ async fn run_pkce_localhost_login(
|
||||
/// arms (strict on the loopback, mismatch-rejecting on the paste).
|
||||
async fn await_pkce_code(
|
||||
redirect_port: u16,
|
||||
redirect_path: &str,
|
||||
pkce: &crate::auth::oauth_pkce::PkceCodes,
|
||||
channels: Option<&mut AuthChannels>,
|
||||
) -> anyhow::Result<String> {
|
||||
@@ -181,7 +225,7 @@ async fn await_pkce_code(
|
||||
match channels {
|
||||
Some(ch) => {
|
||||
tokio::select! {
|
||||
code = oauth_pkce::await_loopback_code(redirect_port, &pkce.state) => code,
|
||||
code = oauth_pkce::await_loopback_code(redirect_port, redirect_path, &pkce.state) => code,
|
||||
pasted = ch.code_rx.recv() => {
|
||||
let pasted = pasted
|
||||
.ok_or_else(|| anyhow::anyhow!("auth code channel closed before a code arrived"))?;
|
||||
@@ -191,7 +235,7 @@ async fn await_pkce_code(
|
||||
}
|
||||
}
|
||||
}
|
||||
None => oauth_pkce::await_loopback_code(redirect_port, &pkce.state).await,
|
||||
None => oauth_pkce::await_loopback_code(redirect_port, redirect_path, &pkce.state).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
//! Generic authorization-code + PKCE (S256) OAuth wire with a `127.0.0.1`
|
||||
//! loopback callback, driven by a registry [`kigi_models::OAuthConfig`] whose
|
||||
//! `flow` is [`OAuthFlow::PkceLocalhost`] (claude-pro-max today).
|
||||
//! `flow` is [`OAuthFlow::PkceLocalhost`] (claude-pro-max JSON + openai-codex
|
||||
//! FORM).
|
||||
//!
|
||||
//! Shape (Pi `earendil-works/pi` `auth/oauth/anthropic.ts`):
|
||||
//! Shape (Pi `earendil-works/pi` `auth/oauth/{anthropic,openai-codex}.ts`):
|
||||
//! - `verifier = base64url(32 random bytes)`; `challenge = base64url(SHA-256(
|
||||
//! verifier))`; `state = verifier`.
|
||||
//! verifier))`. `state` is `verifier` for claude ([`generate_pkce`]) or a
|
||||
//! fresh-random value for codex ([`generate_pkce_random_state`]).
|
||||
//! - Browser opens `{auth_host}{device_path}?client_id&response_type=code&
|
||||
//! scope&redirect_uri&state&code_challenge&code_challenge_method=S256`.
|
||||
//! scope&redirect_uri&state&code_challenge&code_challenge_method=S256` plus any
|
||||
//! `authorize_extra` params (codex only).
|
||||
//! - The code returns to a loopback listener on `127.0.0.1:{redirect_port}`
|
||||
//! answering ONLY `/callback`, with STRICT `state` validation (a mismatch is
|
||||
//! rejected — CSRF guard). A manual paste (redirect URL / `code#state` / bare
|
||||
//! code) is accepted as a headless fallback.
|
||||
//! - Code → token exchange and refresh POST `{token_host}{token_path}` as JSON
|
||||
//! (per `token_body`); the refresh token ROTATES.
|
||||
//! answering ONLY `{redirect_path}` (claude `/callback`, codex
|
||||
//! `/auth/callback`), with STRICT `state` validation (a mismatch is rejected —
|
||||
//! CSRF guard). A manual paste (redirect URL / `code#state` / bare code) is
|
||||
//! accepted as a headless fallback.
|
||||
//! - Code → token exchange POSTs `{token_host}{token_path}` as JSON
|
||||
//! ([`exchange_code`], claude) or FORM ([`exchange_code_form`], codex; NO
|
||||
//! `state` field). Refresh: claude JSON here ([`refresh_token`], rotating);
|
||||
//! codex takes the generic device refresher's FORM path.
|
||||
//!
|
||||
//! SECURITY: the verifier, authorization code, access token, and refresh token
|
||||
//! are NEVER logged (only non-secret events: authorize URL requested, callback
|
||||
@@ -34,16 +40,19 @@ const MAX_REFRESH_RETRIES: u32 = 3;
|
||||
/// HTTP statuses worth retrying a refresh for (parity with the device wire).
|
||||
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
|
||||
|
||||
/// PKCE secrets for one login attempt. `state == verifier` (Pi's convention:
|
||||
/// the state is the verifier, so a returned state binds the callback to this
|
||||
/// attempt AND doubles as the CSRF token).
|
||||
/// PKCE secrets for one login attempt. Two `state` conventions ship:
|
||||
/// [`generate_pkce`] sets `state == verifier` (Pi's/Claude's convention), while
|
||||
/// [`generate_pkce_random_state`] mints an INDEPENDENT random state (the OAuth
|
||||
/// standard, used by ChatGPT/Codex). Either way the state is validated on the
|
||||
/// callback as the CSRF guard.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PkceCodes {
|
||||
/// `code_verifier` — the 43-char base64url secret, sent at token exchange.
|
||||
pub verifier: String,
|
||||
/// `code_challenge = base64url(SHA-256(verifier))`, sent at authorize.
|
||||
pub challenge: String,
|
||||
/// `state` — equal to `verifier`; validated on the callback (CSRF guard).
|
||||
/// `state` — the verifier itself, or an independent random value depending
|
||||
/// on the provider's dialect; validated on the callback (CSRF guard).
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
@@ -63,30 +72,51 @@ pub(crate) fn generate_pkce() -> PkceCodes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback redirect URI for a PKCE-localhost provider.
|
||||
pub(crate) fn redirect_uri(redirect_port: u16) -> String {
|
||||
format!("http://localhost:{redirect_port}/callback")
|
||||
/// Like [`generate_pkce`] but with an INDEPENDENT fresh-random `state` (16
|
||||
/// random bytes) instead of `state == verifier`. The ChatGPT/Codex flow uses a
|
||||
/// distinct state (the verifier never doubles as the CSRF token there), so the
|
||||
/// verifier stays out of the state carried on the loopback callback.
|
||||
pub(crate) fn generate_pkce_random_state() -> PkceCodes {
|
||||
use rand::RngCore;
|
||||
let mut raw = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut raw);
|
||||
let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||
PkceCodes {
|
||||
state,
|
||||
..generate_pkce()
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback redirect URI for a PKCE-localhost provider (claude `/callback`,
|
||||
/// codex `/auth/callback`).
|
||||
pub(crate) fn redirect_uri(redirect_port: u16, redirect_path: &str) -> String {
|
||||
format!("http://localhost:{redirect_port}{redirect_path}")
|
||||
}
|
||||
|
||||
/// Build the browser authorize URL:
|
||||
/// `{auth_host}{device_path}?client_id&response_type=code&scope&redirect_uri&
|
||||
/// state&code_challenge&code_challenge_method=S256`.
|
||||
/// state&code_challenge&code_challenge_method=S256` plus any config
|
||||
/// `authorize_extra` params (empty for every config but codex, so their URLs
|
||||
/// stay byte-identical).
|
||||
pub(crate) fn build_authorize_url(
|
||||
cfg: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
pkce: &PkceCodes,
|
||||
) -> String {
|
||||
let base = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.device_path);
|
||||
let query = url::form_urlencoded::Serializer::new(String::new())
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer
|
||||
.append_pair("client_id", cfg.client_id)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", cfg.scope)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("state", &pkce.state)
|
||||
.append_pair("code_challenge", &pkce.challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.finish();
|
||||
format!("{base}?{query}")
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
for (key, value) in cfg.authorize_extra {
|
||||
serializer.append_pair(key, value);
|
||||
}
|
||||
format!("{base}?{}", serializer.finish())
|
||||
}
|
||||
|
||||
/// `code` + `state` extracted from a callback (loopback query OR manual paste).
|
||||
@@ -240,6 +270,52 @@ pub(crate) async fn exchange_code(
|
||||
Ok(tokens.into_auth())
|
||||
}
|
||||
|
||||
/// Exchange an authorization `code` for a token set with a FORM body (codex):
|
||||
/// `{grant_type=authorization_code, client_id, code, code_verifier,
|
||||
/// redirect_uri}`. Unlike [`exchange_code`], the `state` is NOT sent in the
|
||||
/// token body (the ChatGPT/Codex token endpoint does not expect it). Asserts a
|
||||
/// `Form` config so a JSON provider can never silently mis-encode.
|
||||
pub(crate) async fn exchange_code_form(
|
||||
cfg: &OAuthConfig,
|
||||
code: &str,
|
||||
pkce: &PkceCodes,
|
||||
redirect_uri: &str,
|
||||
) -> anyhow::Result<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).
|
||||
/// Claude ROTATES the refresh token, so the caller MUST persist the returned
|
||||
/// one. Retries the retryable statuses / network errors with exponential
|
||||
@@ -320,13 +396,16 @@ struct OAuthErrorBody {
|
||||
}
|
||||
|
||||
/// Bind a loopback HTTP listener on `127.0.0.1:{redirect_port}` and wait for a
|
||||
/// single `GET /callback?code=…&state=…`, validating `state` STRICTLY against
|
||||
/// `expected_state` (mismatch → rejected). Returns the authorization code.
|
||||
/// single `GET {redirect_path}?code=…&state=…`, validating `state` STRICTLY
|
||||
/// against `expected_state` (mismatch → rejected). Returns the authorization
|
||||
/// code.
|
||||
///
|
||||
/// The listener answers ONLY `/callback`; any other path gets 404. It binds
|
||||
/// `127.0.0.1` (never `0.0.0.0`), so no non-loopback host can reach it.
|
||||
/// The listener answers ONLY `redirect_path` (claude `/callback`, codex
|
||||
/// `/auth/callback`); any other path gets 404. It binds `127.0.0.1` (never
|
||||
/// `0.0.0.0`), so no non-loopback host can reach it.
|
||||
pub(crate) async fn await_loopback_code(
|
||||
redirect_port: u16,
|
||||
redirect_path: &str,
|
||||
expected_state: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", redirect_port))
|
||||
@@ -335,10 +414,10 @@ pub(crate) async fn await_loopback_code(
|
||||
tracing::info!(port = redirect_port, "auth: pkce loopback listener bound");
|
||||
loop {
|
||||
let (stream, _peer) = listener.accept().await.context("loopback accept failed")?;
|
||||
match handle_loopback_conn(stream, expected_state).await {
|
||||
match handle_loopback_conn(stream, redirect_path, expected_state).await {
|
||||
LoopbackOutcome::Code(code) => return Ok(code),
|
||||
LoopbackOutcome::Rejected(err) => return Err(err),
|
||||
// Not the /callback GET (favicon, health probe): keep listening.
|
||||
// Not the callback GET (favicon, health probe): keep listening.
|
||||
LoopbackOutcome::Ignore => continue,
|
||||
}
|
||||
}
|
||||
@@ -355,6 +434,7 @@ enum LoopbackOutcome {
|
||||
/// state is [`LoopbackOutcome::Rejected`] (the browser sees an error page).
|
||||
async fn handle_loopback_conn(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
redirect_path: &str,
|
||||
expected_state: &str,
|
||||
) -> LoopbackOutcome {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
@@ -370,7 +450,7 @@ async fn handle_loopback_conn(
|
||||
let Some(request_line) = head.lines().next() else {
|
||||
return LoopbackOutcome::Ignore;
|
||||
};
|
||||
// `GET /callback?code=…&state=… HTTP/1.1`
|
||||
// `GET {redirect_path}?code=…&state=… HTTP/1.1`
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let (Some(method), Some(target)) = (parts.next(), parts.next()) else {
|
||||
return LoopbackOutcome::Ignore;
|
||||
@@ -380,7 +460,7 @@ async fn handle_loopback_conn(
|
||||
return LoopbackOutcome::Ignore;
|
||||
}
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
if path != "/callback" {
|
||||
if path != redirect_path {
|
||||
let _ = write_http(&mut stream, 404, "Not Found").await;
|
||||
return LoopbackOutcome::Ignore;
|
||||
}
|
||||
@@ -392,7 +472,7 @@ async fn handle_loopback_conn(
|
||||
let _ = write_http(
|
||||
&mut stream,
|
||||
200,
|
||||
"Signed in to Claude Pro/Max. You can close this window and return to kigi.",
|
||||
"Signed in. You can close this window and return to kigi.",
|
||||
)
|
||||
.await;
|
||||
let _ = stream.flush().await;
|
||||
@@ -473,7 +553,7 @@ mod tests {
|
||||
#[test]
|
||||
fn authorize_url_has_state_and_s256_challenge() {
|
||||
let pkce = generate_pkce();
|
||||
let redirect = redirect_uri(53692);
|
||||
let redirect = redirect_uri(53692, "/callback");
|
||||
let url = build_authorize_url(&CLAUDE_OAUTH_CONFIG, &redirect, &pkce);
|
||||
let parsed = url::Url::parse(&url).expect("valid URL");
|
||||
assert_eq!(parsed.host_str(), Some("claude.ai"));
|
||||
@@ -545,7 +625,10 @@ mod tests {
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
|
||||
let server = tokio::spawn(async move { await_loopback_code(port, "the-real-state").await });
|
||||
let server =
|
||||
tokio::spawn(
|
||||
async move { await_loopback_code(port, "/callback", "the-real-state").await },
|
||||
);
|
||||
// Give the listener a moment to bind.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
// Attacker callback: valid code, WRONG state.
|
||||
@@ -567,7 +650,8 @@ mod tests {
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
|
||||
let server = tokio::spawn(async move { await_loopback_code(port, "good-state").await });
|
||||
let server =
|
||||
tokio::spawn(async move { await_loopback_code(port, "/callback", "good-state").await });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let _ = reqwest::get(format!(
|
||||
"http://127.0.0.1:{port}/callback?code=auth-code-123&state=good-state"
|
||||
@@ -622,9 +706,14 @@ mod tests {
|
||||
.await;
|
||||
let cfg = mock_cfg(host);
|
||||
let pkce = generate_pkce();
|
||||
let auth = exchange_code(&cfg, "auth-code-xyz", &pkce, &redirect_uri(53692))
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = exchange_code(
|
||||
&cfg,
|
||||
"auth-code-xyz",
|
||||
&pkce,
|
||||
&redirect_uri(53692, "/callback"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(auth.key, "sk-ant-oat-new");
|
||||
assert_eq!(auth.refresh_token.as_deref(), Some("sk-ant-ort-new"));
|
||||
assert_eq!(auth.expires_in, Some(3600));
|
||||
@@ -684,4 +773,132 @@ mod tests {
|
||||
other => panic!("expected Unauthorized, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ChatGPT/Codex PKCE (openai-codex) ────────────────────────────────────
|
||||
|
||||
/// Codex PKCE uses an INDEPENDENT fresh-random state (NOT `state ==
|
||||
/// verifier`) so the verifier never rides the callback.
|
||||
#[test]
|
||||
fn codex_pkce_state_is_independent_of_the_verifier() {
|
||||
let pkce = generate_pkce_random_state();
|
||||
assert_ne!(
|
||||
pkce.state, pkce.verifier,
|
||||
"codex state must be fresh-random, not the verifier"
|
||||
);
|
||||
assert!(!pkce.state.is_empty() && !pkce.verifier.is_empty());
|
||||
// Challenge is still the S256 of the verifier.
|
||||
let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(Sha256::digest(pkce.verifier.as_bytes()));
|
||||
assert_eq!(pkce.challenge, expect);
|
||||
assert_ne!(
|
||||
generate_pkce_random_state().state,
|
||||
pkce.state,
|
||||
"fresh state"
|
||||
);
|
||||
}
|
||||
|
||||
/// The codex authorize URL carries the PKCE state + S256 challenge AND the
|
||||
/// three codex-only extra params, and targets `auth.openai.com/oauth/
|
||||
/// authorize` with the `/auth/callback` redirect. The verifier never rides it.
|
||||
#[test]
|
||||
fn codex_authorize_url_has_state_challenge_and_three_extra_params() {
|
||||
use kigi_models::CODEX_OAUTH_CONFIG;
|
||||
let pkce = generate_pkce_random_state();
|
||||
let redirect = redirect_uri(1455, "/auth/callback");
|
||||
assert_eq!(redirect, "http://localhost:1455/auth/callback");
|
||||
let url = build_authorize_url(&CODEX_OAUTH_CONFIG, &redirect, &pkce);
|
||||
let parsed = url::Url::parse(&url).expect("valid URL");
|
||||
assert_eq!(parsed.host_str(), Some("auth.openai.com"));
|
||||
assert_eq!(parsed.path(), "/oauth/authorize");
|
||||
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
|
||||
assert_eq!(
|
||||
q.get("state").map(String::as_str),
|
||||
Some(pkce.state.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("code_challenge").map(String::as_str),
|
||||
Some(pkce.challenge.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("code_challenge_method").map(String::as_str),
|
||||
Some("S256")
|
||||
);
|
||||
// The three codex-only extra params.
|
||||
assert_eq!(
|
||||
q.get("id_token_add_organizations").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("codex_cli_simplified_flow").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("originator").map(String::as_str),
|
||||
Some("codex_cli_rs")
|
||||
);
|
||||
assert!(
|
||||
!url.contains("code_verifier"),
|
||||
"the verifier must not ride the authorize URL"
|
||||
);
|
||||
}
|
||||
|
||||
/// The claude authorize URL is UNCHANGED (no extra params) — its empty
|
||||
/// `authorize_extra` keeps it byte-identical.
|
||||
#[test]
|
||||
fn claude_authorize_url_carries_no_extra_params() {
|
||||
let pkce = generate_pkce();
|
||||
let url = build_authorize_url(
|
||||
&CLAUDE_OAUTH_CONFIG,
|
||||
&redirect_uri(53692, "/callback"),
|
||||
&pkce,
|
||||
);
|
||||
assert!(!url.contains("id_token_add_organizations"));
|
||||
assert!(!url.contains("codex_cli_simplified_flow"));
|
||||
assert!(!url.contains("originator"));
|
||||
}
|
||||
|
||||
fn codex_mock_cfg(token_host: &'static str) -> OAuthConfig {
|
||||
OAuthConfig {
|
||||
token_host,
|
||||
..kigi_models::CODEX_OAUTH_CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex code→token exchange posts a FORM body carrying the grant + code +
|
||||
/// verifier + redirect_uri, and NOTABLY NO `state` field (the codex token
|
||||
/// endpoint does not expect it). Response materializes a `KimiAuth`.
|
||||
#[tokio::test]
|
||||
async fn codex_exchange_code_posts_form_without_state() {
|
||||
use wiremock::matchers::{body_string_contains, header, method, path};
|
||||
let server = MockServer::start().await;
|
||||
let host: &'static str = Box::leak(server.uri().into_boxed_str());
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.and(header("content-type", "application/x-www-form-urlencoded"))
|
||||
.and(body_string_contains("grant_type=authorization_code"))
|
||||
.and(body_string_contains("code=codex-auth-code"))
|
||||
.and(body_string_contains("code_verifier="))
|
||||
.and(body_string_contains("redirect_uri="))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"access_token": "codex-access-jwt",
|
||||
"refresh_token": "codex-refresh",
|
||||
"expires_in": 3600,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let cfg = codex_mock_cfg(host);
|
||||
let pkce = generate_pkce_random_state();
|
||||
let auth = exchange_code_form(
|
||||
&cfg,
|
||||
"codex-auth-code",
|
||||
&pkce,
|
||||
"http://localhost:1455/auth/callback",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(auth.key, "codex-access-jwt");
|
||||
assert_eq!(auth.refresh_token.as_deref(), Some("codex-refresh"));
|
||||
assert_eq!(auth.expires_in, Some(3600));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,46 @@ mod tests {
|
||||
.expect("github-copilot carries an OAuthConfig")
|
||||
}
|
||||
|
||||
fn codex_oauth() -> &'static kigi_models::OAuthConfig {
|
||||
kigi_models::PlatformId::OpenaiCodex
|
||||
.oauth()
|
||||
.expect("openai-codex carries an OAuthConfig")
|
||||
}
|
||||
|
||||
/// An `openai-codex/<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
|
||||
/// github-copilot manager (its OWN `oauth/github-copilot` scope), NEVER the
|
||||
/// 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
|
||||
//! `grant_type=refresh_token` for any [`kigi_models::OAuthConfig`] provider
|
||||
//! (xai-grok today) through the [`TokenRefresher`] seam.
|
||||
//! Generic OAuth token refresher for any [`kigi_models::OAuthConfig`] provider,
|
||||
//! driven through the [`TokenRefresher`] seam. The wire call is selected by the
|
||||
//! config's `token_body`: form-encoded `grant_type=refresh_token` (xai-grok,
|
||||
//! openai-codex), JSON (claude-pro-max), or the GitHub Copilot copilot-token
|
||||
//! RE-MINT. Providers with `requires_chatgpt_account_id` additionally fail fast
|
||||
//! when the refreshed token drops the claim.
|
||||
//!
|
||||
//! Structurally identical to [`super::kimi_refresher::KimiRefresher`] — same
|
||||
//! sibling-adoption + post-401 grace — but the wire call goes through
|
||||
@@ -118,6 +121,22 @@ impl TokenRefresher for GenericDeviceRefresher {
|
||||
}
|
||||
};
|
||||
match wire_result {
|
||||
Ok(new_auth)
|
||||
if self.cfg.requires_chatgpt_account_id
|
||||
&& kigi_sampling_types::chatgpt_account_id_from_jwt(&new_auth.key)
|
||||
.is_none() =>
|
||||
{
|
||||
// FAIL FAST (never silently): the refreshed token carries no
|
||||
// `chatgpt_account_id`, so every inference request would go out
|
||||
// WITHOUT the required `chatgpt-account-id` header and draw an
|
||||
// opaque backend 4xx. Surface it as a permanent failure so the
|
||||
// user is told to re-login.
|
||||
tracing::warn!(
|
||||
scope_key = self.cfg.scope_key,
|
||||
"auth: refreshed token is missing the chatgpt_account_id claim"
|
||||
);
|
||||
RefreshOutcome::permanent(RefreshTokenFailedReason::Other, Some(refresh_token))
|
||||
}
|
||||
Ok(new_auth) => {
|
||||
kigi_log::unified_log::info(
|
||||
"auth.refresh.token_rotated",
|
||||
|
||||
@@ -283,6 +283,16 @@ impl SessionActor {
|
||||
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
|
||||
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers())
|
||||
}
|
||||
/// Whether `model` routes to the ChatGPT/Codex Responses platform
|
||||
/// (openai-codex) — the gate for the sampler's Codex identity headers
|
||||
/// (`chatgpt-account-id` + originator + OpenAI-Beta). Every other model
|
||||
/// returns `false`, keeping the API-key `openai` Responses request
|
||||
/// byte-identical.
|
||||
fn model_is_openai_codex(&self, model: &str) -> bool {
|
||||
let managed_key = self.managed_key_for_model(model);
|
||||
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
|
||||
.is_some_and(|(platform, _)| platform.sends_codex_responses_headers())
|
||||
}
|
||||
/// LEAK guard for the stamped aux paths (auto-mode classifier, image
|
||||
/// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`]
|
||||
/// has copied the SESSION model's `bearer_resolver` onto an aux
|
||||
@@ -394,6 +404,8 @@ impl SessionActor {
|
||||
let anthropic_oauth = self.model_is_anthropic_oauth(&cfg.model);
|
||||
// GitHub Copilot editor-identity headers for THIS turn's model.
|
||||
let github_copilot = self.model_is_github_copilot(&cfg.model);
|
||||
// ChatGPT/Codex identity headers for THIS turn's model.
|
||||
let openai_codex = self.model_is_openai_codex(&cfg.model);
|
||||
let auth_scheme = model_facts.auth_scheme;
|
||||
let mut extra_headers = cfg.extra_headers;
|
||||
crate::agent::config::inject_url_derived_headers(
|
||||
@@ -436,6 +448,7 @@ impl SessionActor {
|
||||
auth_scheme,
|
||||
anthropic_oauth,
|
||||
github_copilot,
|
||||
openai_codex,
|
||||
chat_compat: cfg.chat_compat,
|
||||
extra_headers,
|
||||
context_window: cfg.context_window.get(),
|
||||
|
||||
@@ -851,6 +851,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
extra_headers: Default::default(),
|
||||
context_window: 256_000,
|
||||
force_http1: false,
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
extra_headers: Default::default(),
|
||||
context_window: 100_000,
|
||||
force_http1: false,
|
||||
@@ -346,6 +347,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
context_window: 100_000,
|
||||
force_http1: false,
|
||||
max_retries: None,
|
||||
@@ -478,6 +480,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
context_window: 100_000,
|
||||
force_http1: false,
|
||||
max_retries: None,
|
||||
@@ -1748,6 +1751,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
extra_headers: Default::default(),
|
||||
context_window: 100_000,
|
||||
force_http1: false,
|
||||
|
||||
@@ -1592,6 +1592,7 @@ mod reasoning_compaction_regression_tests {
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
extra_headers: Default::default(),
|
||||
context_window: 256_000,
|
||||
force_http1: false,
|
||||
|
||||
@@ -48,6 +48,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
extra_headers: Default::default(),
|
||||
context_window: 256_000,
|
||||
force_http1: false,
|
||||
|
||||
@@ -40,6 +40,7 @@ pub fn test_sampler_config(
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
github_copilot: false,
|
||||
openai_codex: false,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: extra_headers
|
||||
.iter()
|
||||
|
||||
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
||||
assert_eq!(items.len(), 29, "28 login rows + Quit, got {items:?}");
|
||||
assert_eq!(items.len(), 30, "29 login rows + Quit, got {items:?}");
|
||||
assert!(
|
||||
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
|
||||
"row 0 must be the OAuth login, got {:?}",
|
||||
@@ -6915,22 +6915,27 @@ pub(crate) mod tests {
|
||||
"row 3 must be the github-copilot OAuth login (after claude-pro-max), got {:?}",
|
||||
items[3]
|
||||
);
|
||||
assert!(
|
||||
matches!(&items[4], PendingMenuItem::Login { label } if label == "ChatGPT Plus/Pro (Codex) (OAuth)"),
|
||||
"row 4 must be the openai-codex OAuth login (after github-copilot), got {:?}",
|
||||
items[4]
|
||||
);
|
||||
assert_eq!(
|
||||
items[4],
|
||||
items[5],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn),
|
||||
label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[5],
|
||||
items[6],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi),
|
||||
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[6],
|
||||
items[7],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi),
|
||||
label: "OpenAI (API key)".into(),
|
||||
@@ -6938,153 +6943,153 @@ pub(crate) mod tests {
|
||||
"new registry rows must appear in the picker with zero TUI changes"
|
||||
);
|
||||
assert_eq!(
|
||||
items[7],
|
||||
items[8],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic),
|
||||
label: "Anthropic (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[8],
|
||||
items[9],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek),
|
||||
label: "DeepSeek (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[9],
|
||||
items[10],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Groq),
|
||||
label: "Groq (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[10],
|
||||
items[11],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Mistral),
|
||||
label: "Mistral (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[11],
|
||||
items[12],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks),
|
||||
label: "Fireworks AI (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[12],
|
||||
items[13],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Google),
|
||||
label: "Google Gemini (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[13],
|
||||
items[14],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter),
|
||||
label: "OpenRouter (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[14],
|
||||
items[15],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Together),
|
||||
label: "Together AI (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[15],
|
||||
items[16],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras),
|
||||
label: "Cerebras (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[16],
|
||||
items[17],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia),
|
||||
label: "NVIDIA NIM (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[17],
|
||||
items[18],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Vercel),
|
||||
label: "Vercel AI Gateway (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[18],
|
||||
items[19],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Xai),
|
||||
label: "xAI (Grok) (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[19],
|
||||
items[20],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan),
|
||||
label: "Qwen Token Plan (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[20],
|
||||
items[21],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn),
|
||||
label: "Qwen Token Plan China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[21],
|
||||
items[22],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding),
|
||||
label: "Kimi For Coding (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[22],
|
||||
items[23],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Zai),
|
||||
label: "Z.AI (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[23],
|
||||
items[24],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn),
|
||||
label: "Z.AI Coding China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[24],
|
||||
items[25],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi),
|
||||
label: "Xiaomi MiMo (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[25],
|
||||
items[26],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn),
|
||||
label: "Xiaomi Token Plan China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[26],
|
||||
items[27],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Minimax),
|
||||
label: "MiniMax (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[27],
|
||||
items[28],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn),
|
||||
label: "MiniMax China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(items[28], PendingMenuItem::Quit);
|
||||
assert_eq!(items[29], PendingMenuItem::Quit);
|
||||
// The non-interactive methods must never appear as rows.
|
||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||
@@ -7095,8 +7100,8 @@ pub(crate) mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
pending_menu_items(&byok.methods, None).len(),
|
||||
29,
|
||||
"xai.api_key / cached_token must not add rows (28 login rows + Quit)"
|
||||
30,
|
||||
"xai.api_key / cached_token must not add rows (29 login rows + Quit)"
|
||||
);
|
||||
}
|
||||
/// Startup lands on the picker only when there is a real choice: the
|
||||
@@ -7118,8 +7123,10 @@ pub(crate) mod tests {
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
app.welcome_prompt_focused = false;
|
||||
// Interactive OAuth logins come first: row 0 (kimi-code), row 1
|
||||
// (xai-grok), row 2 (claude-pro-max), row 3 (github-copilot); the first
|
||||
// API-key row (moonshot-cn) is now row 4, so five Downs land on it.
|
||||
// (xai-grok), row 2 (claude-pro-max), row 3 (github-copilot), row 4
|
||||
// (openai-codex); the first API-key row (moonshot-cn) is now row 5, so
|
||||
// six Downs land on it.
|
||||
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
|
||||
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
|
||||
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
|
||||
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) fn effort_description(level: ReasoningEffort) -> &'static str {
|
||||
ReasoningEffort::High => "Heavy reasoning",
|
||||
ReasoningEffort::Xhigh => "Extra-heavy reasoning",
|
||||
ReasoningEffort::Max => "Maximum reasoning",
|
||||
ReasoningEffort::Ultra => "Ultra reasoning",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2118,6 +2118,10 @@ mod tests {
|
||||
text.contains("GitHub Copilot (subscription) (OAuth)"),
|
||||
"the github-copilot interactive OAuth login row must render: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("ChatGPT Plus/Pro (Codex) (OAuth)"),
|
||||
"the openai-codex interactive OAuth login row must render: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
|
||||
"{text}"
|
||||
|
||||
Reference in New Issue
Block a user