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