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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user