feat(providers): add Claude Pro/Max subscription OAuth (PKCE-localhost)

27th registry variant, 2nd subscription-OAuth provider. Log in with a Claude
Pro/Max subscription via PKCE authorization-code + S256 (loopback callback on
127.0.0.1:53692, with a manual code-paste fallback), then use it against
api.anthropic.com — reusing the existing Anthropic Messages wire + Anthropic
listing + the multi-provider OAuth foundation (dbce6bf). Sourced from Pi
(earendil-works/pi auth/oauth/anthropic.ts): client 9d1c250a..., authorize
claude.ai/oauth/authorize, token platform.claude.com/v1/oauth/token, scope
'…user:inference user:sessions:claude_code…'.

New machinery (foundation handles token routing — claude-pro-max is a
uses_oauth platform so its bearer/refresh/api_key already route to its own
pooled manager, never Kimi):
- OAuthConfig gains flow{DeviceCode|PkceLocalhost} + token_host + token_body
  {Form|JSON}; xai/kimi rows unchanged (DeviceCode/Form).
- auth/oauth_pkce.rs: PKCE S256 wire — loopback listener with STRICT state
  validation (CSRF, fail-closed), manual-paste fallback, JSON code→token
  exchange + rotating-refresh. Never logs code/verifier/tokens.
- Messages OAuth adaptation gated on SamplerConfig.anthropic_oauth (true only
  for a claude-pro-max managed key): Authorization: Bearer + anthropic-beta
  oauth + user-agent claude-cli + x-app cli, and the required 'You are Claude
  Code' system prefix. API-key anthropic/minimax Messages requests are
  BYTE-IDENTICAL (regression-guarded).
- Live /models under the OAuth Bearer + oauth-beta headers (Anthropic listing,
  enriched from models.dev anthropic); persistent 401 → 0 models + WARN, NO
  hardcoded fallback list (honest failure).

Adversarial review: no blocking findings (secret handling, CSRF/state, the
anthropic_oauth gate, token routing, non-regression all CONFIRMED). Full gate
green. Registry at 27; picker updated. Residual (unverifiable without a real
Claude Pro/Max account): whether GET /v1/models accepts the OAuth bearer, and
the real endpoint's acceptance of the OAuth Messages request.
This commit is contained in:
2026-07-22 02:53:25 -04:00
parent dbce6bf305
commit 5a9183b08b
25 changed files with 1527 additions and 73 deletions
+30 -15
View File
@@ -168,21 +168,36 @@ edges stay deterministic Rust. The harness appends a terminal
are built generically from advertised methods (`AuthMethodKind:: are built generically from advertised methods (`AuthMethodKind::
ApiKeyPlatform`), so new registry rows appear in the picker with no TUI ApiKeyPlatform`), so new registry rows appear in the picker with no TUI
changes. changes.
- Refreshable-OAuth providers beyond Kimi Code use a GENERIC device-code - Refreshable-OAuth providers beyond Kimi Code use a GENERIC path, NOT Kimi's
(RFC-8628) path, NOT Kimi's bespoke wire. A `uses_oauth` platform carrying bespoke wire. A `uses_oauth` platform carrying `oauth: Some(&OAuthConfig)`
`oauth: Some(&OAuthConfig)` (client id / auth host / device+token paths / (client id / auth host / start+token paths / `token_host` / `scope` /
scope / `scope_key` / optional extra device field) drives `scope_key` / optional extra device field / `flow` / `token_body`) drives a
`auth::oauth_device` (plain kigi UA, no X-Msh headers) + a scope-keyed scope-keyed `AuthManager::new_oauth_provider` +
`AuthManager::new_oauth_provider` + `refresh::GenericDeviceRefresher` `refresh::GenericDeviceRefresher` (selected by `build_refresher` via
(selected by `build_refresher` via `oauth_config_for_scope_key`). Kimi Code `oauth_config_for_scope_key`; the refresher dispatches the refresh body by
keeps `oauth: None` and its bespoke path unchanged. First such provider: `token_body`: form → `auth::oauth_device`, JSON → `auth::oauth_pkce`). Kimi
`xai-grok` (`scope_key oauth/xai`, base `api.x.ai/v1`, same wire as the Code keeps `oauth: None` and its bespoke path unchanged. The interactive
API-key `xai` row) — an INTERACTIVE login row advertised right after login is dispatched by `OAuthConfig.flow` (in `run_oauth_provider_flow`):
`kimi-code` (`AuthMethodKind::OAuthPlatform`). Its `authenticate` arm runs - `OAuthFlow::DeviceCode` `auth::oauth_device` (RFC-8628 device-code, plain
the generic device flow under its own scope; the catalog fetch resolves each kigi UA, no X-Msh headers). Provider: `xai-grok` (`scope_key oauth/xai`,
such platform's OWN session token (`resolve_generic_oauth_tokens`, refreshed base `api.x.ai/v1`, form token body, same wire as the API-key `xai` row).
on expiry) and routes `platform.oauth().is_some()``platform.base_url()` - `OAuthFlow::PkceLocalhost { redirect_port }``auth::oauth_pkce`
(kimi-code alone → `proxy_url()`). Tokens are NEVER logged. (authorization-code + PKCE S256, `127.0.0.1:redirect_port/callback` loopback
with STRICT `state` validation + manual-paste fallback, JSON token body,
authorize host ≠ token host). Provider: `claude-pro-max` (`scope_key
oauth/claude-pro-max`, base `api.anthropic.com/v1`, Anthropic Messages +
listing wire reached with an OAuth `sk-ant-oat…` Bearer). Its Messages
requests take the OAuth adaptation — `anthropic-beta claude-code-…,oauth-…`
+ `claude-cli` UA + `x-app cli` + the required "You are Claude Code…" system
prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max only), so
API-key `anthropic`/`minimax` Messages requests stay byte-identical. Its
`/v1/models` listing rides the same Bearer + oauth-beta headers.
Both are INTERACTIVE login rows advertised right after `kimi-code`
(`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok` then
`claude-pro-max`). The catalog fetch resolves each such platform's OWN session
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
`platform.oauth().is_some()``platform.base_url()` (kimi-code alone →
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
- Model metadata (context window, thinking levels) comes from the provider - Model metadata (context window, thinking levels) comes from the provider
wire when served; metadata-poor listings are enriched from models.dev wire when served; metadata-poor listings are enriched from models.dev
(`kigi-models/src/enrichment.rs` — bundled raw snapshot regenerated by (`kigi-models/src/enrichment.rs` — bundled raw snapshot regenerated by
+175 -10
View File
@@ -96,8 +96,35 @@ enum BaseUrlSource {
}, },
} }
/// Generic RFC-8628 device-code OAuth configuration carried by a `uses_oauth` /// The interactive login mechanism a `uses_oauth` [`OAuthConfig`] provider
/// platform whose login is the GENERIC device-code path (xai-grok today). /// drives. `DeviceCode` is the RFC-8628 device flow (xai-grok); `PkceLocalhost`
/// is the authorization-code + PKCE (S256) flow with a loopback callback
/// (Claude Pro/Max). Kimi Code carries neither (its bespoke flow lives in
/// kigi-shell with `oauth: None`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthFlow {
/// RFC-8628 device-code: POST `device_path`, poll `token_path`.
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 },
}
/// Body encoding a provider's token endpoint expects for the code-exchange and
/// refresh POSTs. xAI's `/oauth2/token` is form-encoded; Claude's
/// `/v1/oauth/token` is JSON.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthTokenBody {
/// `application/x-www-form-urlencoded` (xai-grok device wire).
Form,
/// `application/json` (Claude PKCE wire).
Json,
}
/// Generic OAuth configuration carried by a `uses_oauth` platform whose login
/// is the GENERIC device-code path (xai-grok) or the PKCE-localhost path
/// (claude-pro-max).
/// ///
/// Kimi Code keeps its bespoke device flow (client id, `/api/oauth/*` paths, /// Kimi Code keeps its bespoke device flow (client id, `/api/oauth/*` paths,
/// X-Msh device headers, `kigi_env::oauth_host()`); its `oauth` field stays /// X-Msh device headers, `kigi_env::oauth_host()`); its `oauth` field stays
@@ -105,15 +132,22 @@ enum BaseUrlSource {
/// tokens they mint are NEVER stored in this struct and never logged. /// tokens they mint are NEVER stored in this struct and never logged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OAuthConfig { pub struct OAuthConfig {
/// OAuth client id sent on every device-authorization / token call. /// OAuth client id sent on every authorize / token call.
pub client_id: &'static str, pub client_id: &'static str,
/// Authorization-server origin (no trailing slash), e.g. `https://auth.x.ai`. /// Authorization-server origin (no trailing slash), e.g. `https://auth.x.ai`
/// (device) or `https://claude.ai` (PKCE authorize host).
pub auth_host: &'static str, pub auth_host: &'static str,
/// Device-authorization path (POST), relative to `auth_host`. /// Start-endpoint path (POST for `DeviceCode` device-authorization; the
/// browser authorize path for `PkceLocalhost`), relative to `auth_host`.
pub device_path: &'static str, pub device_path: &'static str,
/// Token path (POST) — used for BOTH the device grant and refresh. /// Token-endpoint origin (no trailing slash). Equals `auth_host` for the
/// device wire; for Claude the token host (`https://platform.claude.com`)
/// differs from the authorize host (`https://claude.ai`).
pub token_host: &'static str,
/// Token path (POST) — used for BOTH the initial grant and refresh,
/// relative to `token_host`.
pub token_path: &'static str, pub token_path: &'static str,
/// OAuth scope string requested at device authorization. /// OAuth scope string requested at authorization.
pub scope: &'static str, pub scope: &'static str,
/// auth.json map key + keyring entry name for this provider's persisted /// auth.json map key + keyring entry name for this provider's persisted
/// session (e.g. `oauth/xai`). /// session (e.g. `oauth/xai`).
@@ -121,6 +155,10 @@ pub struct OAuthConfig {
/// A non-standard extra form field sent ONLY on the device-authorization /// A non-standard extra form field sent ONLY on the device-authorization
/// request (e.g. `("referrer", "kigi")`). `None` = no extra field. /// request (e.g. `("referrer", "kigi")`). `None` = no extra field.
pub extra_device_field: Option<(&'static str, &'static str)>, pub extra_device_field: Option<(&'static str, &'static str)>,
/// Interactive login mechanism (device-code vs PKCE-localhost).
pub flow: OAuthFlow,
/// Body encoding the token endpoint expects (form vs JSON).
pub token_body: OAuthTokenBody,
} }
/// xAI / Grok subscription device-code OAuth (ported from Pi /// xAI / Grok subscription device-code OAuth (ported from Pi
@@ -129,10 +167,36 @@ pub const XAI_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
client_id: "b1a00492-073a-47ea-816f-4c329264a828", client_id: "b1a00492-073a-47ea-816f-4c329264a828",
auth_host: "https://auth.x.ai", auth_host: "https://auth.x.ai",
device_path: "/oauth2/device/code", device_path: "/oauth2/device/code",
token_host: "https://auth.x.ai",
token_path: "/oauth2/token", token_path: "/oauth2/token",
scope: "openid profile email offline_access grok-cli:access api:access", scope: "openid profile email offline_access grok-cli:access api:access",
scope_key: "oauth/xai", scope_key: "oauth/xai",
extra_device_field: Some(("referrer", "kigi")), extra_device_field: Some(("referrer", "kigi")),
flow: OAuthFlow::DeviceCode,
token_body: OAuthTokenBody::Form,
};
/// Base-URL override for the Claude Pro/Max OAuth channel (dev/test escape
/// hatch). Production defaults to `https://api.anthropic.com/v1`.
pub const CLAUDE_OAUTH_BASE_URL_ENV: &str = "KIGI_CLAUDE_OAUTH_BASE_URL";
/// Claude Pro/Max subscription OAuth (authorization-code + PKCE S256, loopback
/// callback). Authoritative constants from Pi `earendil-works/pi`
/// `auth/oauth/anthropic.ts`: authorize host `https://claude.ai`, token host
/// `https://platform.claude.com` (JSON body), public Claude Code client id.
pub const CLAUDE_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
auth_host: "https://claude.ai",
device_path: "/oauth/authorize",
token_host: "https://platform.claude.com",
token_path: "/v1/oauth/token",
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,
flow: OAuthFlow::PkceLocalhost {
redirect_port: 53692,
},
token_body: OAuthTokenBody::Json,
}; };
/// The generic device-code OAuth config for a platform, or `None` for API-key /// The generic device-code OAuth config for a platform, or `None` for API-key
@@ -1001,6 +1065,42 @@ const MINIMAX_CN_SPEC: PlatformSpec = PlatformSpec {
restrict_to_enriched: false, restrict_to_enriched: false,
}; };
/// Claude Pro/Max subscription via PKCE-localhost OAuth. Reaches
/// `api.anthropic.com` with an OAuth `sk-ant-oat…` bearer (NOT an API key) —
/// same Anthropic Messages + listing wire as the API-key `anthropic` row, plus
/// the OAuth identity headers + "You are Claude Code" system prefix (gated on
/// this platform's OAuth path in the sampler/fetch, so the API-key rows stay
/// byte-identical).
const CLAUDE_PRO_MAX_SPEC: PlatformSpec = PlatformSpec {
id: "claude-pro-max",
display_name: "Claude Pro/Max",
// WITH /v1 so listing → /v1/models and inference → /v1/messages, matching
// ANTHROPIC_SPEC's base handling.
base_url: BaseUrlSource::EnvOr {
env: CLAUDE_OAUTH_BASE_URL_ENV,
default: "https://api.anthropic.com/v1",
},
uses_oauth: true,
oauth: Some(&CLAUDE_OAUTH_CONFIG),
allowed_model_prefixes: None,
// OAuth channel: no API key envs (the PKCE session is the bearer).
api_key_envs: &[],
vendor: "Anthropic",
console_host: None,
login_label: Some("Claude Pro/Max (subscription)"),
models_dev_id: Some("anthropic"),
wire_serves_metadata: false,
wire_api: PlatformWireApi::Messages,
listing: ListingDialect::Anthropic,
// Passthrough is ignored for the Messages backend.
chat_compat: PlatformChatCompat::Passthrough,
// OAuth uses Authorization: Bearer, NOT x-api-key.
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no /// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2). /// dynamic provider registration (PRD F2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -1057,12 +1157,15 @@ pub enum PlatformId {
MinimaxCn, MinimaxCn,
/// xAI Grok subscription via device-code OAuth (same wire as `Xai`). /// xAI Grok subscription via device-code OAuth (same wire as `Xai`).
XaiGrok, XaiGrok,
/// Claude Pro/Max subscription via PKCE-localhost OAuth (Anthropic Messages
/// wire reached with an OAuth bearer instead of an API key).
ClaudeProMax,
} }
impl PlatformId { impl PlatformId {
/// All platforms, in catalog precedence order: the subscription channel /// All platforms, in catalog precedence order: the subscription channel
/// first so "default model = first list item" favors it when present. /// first so "default model = first list item" favors it when present.
pub const ALL: [PlatformId; 26] = [ pub const ALL: [PlatformId; 27] = [
Self::KimiCode, Self::KimiCode,
Self::MoonshotCn, Self::MoonshotCn,
Self::MoonshotAi, Self::MoonshotAi,
@@ -1089,6 +1192,7 @@ impl PlatformId {
Self::Minimax, Self::Minimax,
Self::MinimaxCn, Self::MinimaxCn,
Self::XaiGrok, Self::XaiGrok,
Self::ClaudeProMax,
]; ];
/// The registry row backing this platform (single source of per-platform /// The registry row backing this platform (single source of per-platform
@@ -1121,6 +1225,7 @@ impl PlatformId {
Self::Minimax => &MINIMAX_SPEC, Self::Minimax => &MINIMAX_SPEC,
Self::MinimaxCn => &MINIMAX_CN_SPEC, Self::MinimaxCn => &MINIMAX_CN_SPEC,
Self::XaiGrok => &XAI_GROK_SPEC, Self::XaiGrok => &XAI_GROK_SPEC,
Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC,
} }
} }
@@ -1899,7 +2004,7 @@ mod tests {
let g = PlatformId::XaiGrok; let g = PlatformId::XaiGrok;
assert_eq!(g.as_str(), "xai-grok"); assert_eq!(g.as_str(), "xai-grok");
assert!(g.uses_oauth()); assert!(g.uses_oauth());
// The only two uses_oauth platforms; only xai-grok carries a config. // Kimi Code is uses_oauth yet carries no generic config (bespoke flow).
assert!(PlatformId::KimiCode.uses_oauth()); assert!(PlatformId::KimiCode.uses_oauth());
assert_eq!(PlatformId::KimiCode.oauth(), None); assert_eq!(PlatformId::KimiCode.oauth(), None);
let cfg = g let cfg = g
@@ -1933,6 +2038,65 @@ mod tests {
assert_eq!(g.base_url(), "https://mock.grok/v1"); assert_eq!(g.base_url(), "https://mock.grok/v1");
} }
/// claude-pro-max is the first PKCE-localhost OAuth platform: it carries a
/// PKCE `OAuthConfig` (authorize host ≠ token host, JSON token body), reuses
/// the Anthropic Messages + listing wire with a Bearer key header (OAuth,
/// NOT x-api-key), enriches from models.dev "anthropic", and keys its models
/// under `claude-pro-max/`.
#[test]
fn claude_pro_max_is_a_pkce_oauth_platform() {
let c = PlatformId::ClaudeProMax;
assert_eq!(c.as_str(), "claude-pro-max");
assert!(c.uses_oauth());
let cfg = c
.oauth()
.expect("claude-pro-max carries a PKCE OAuthConfig");
assert_eq!(cfg, &CLAUDE_OAUTH_CONFIG);
assert_eq!(cfg.client_id, "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
// Authorize host ≠ token host (the distinguishing PKCE trait).
assert_eq!(cfg.auth_host, "https://claude.ai");
assert_eq!(cfg.device_path, "/oauth/authorize");
assert_eq!(cfg.token_host, "https://platform.claude.com");
assert_eq!(cfg.token_path, "/v1/oauth/token");
assert_eq!(
cfg.scope,
"org:create_api_key user:profile user:inference \
user:sessions:claude_code user:mcp_servers user:file_upload"
);
assert_eq!(cfg.scope_key, "oauth/claude-pro-max");
assert_eq!(cfg.extra_device_field, None);
assert_eq!(
cfg.flow,
OAuthFlow::PkceLocalhost {
redirect_port: 53692
}
);
assert_eq!(cfg.token_body, OAuthTokenBody::Json);
// xai stays the device-code / form contract — unaffected.
assert_eq!(XAI_OAUTH_CONFIG.flow, OAuthFlow::DeviceCode);
assert_eq!(XAI_OAUTH_CONFIG.token_body, OAuthTokenBody::Form);
assert_eq!(XAI_OAUTH_CONFIG.token_host, "https://auth.x.ai");
// Scope-key lookup resolves the config (drives the generic refresher).
assert_eq!(
oauth_config_for_scope_key("oauth/claude-pro-max"),
Some(&CLAUDE_OAUTH_CONFIG)
);
// Anthropic Messages + listing wire, reached with a Bearer OAuth token.
assert_eq!(c.models_dev_id(), Some("anthropic"));
assert!(!c.restrict_to_enriched());
assert_eq!(c.key_header(), PlatformKeyHeader::Bearer);
assert_eq!(c.wire_api(), PlatformWireApi::Messages);
assert_eq!(c.listing(), ListingDialect::Anthropic);
assert_eq!(c.api_key_env_names(), &[] as &[&str]);
assert_eq!(
c.managed_model_key("claude-opus-4-8"),
"claude-pro-max/claude-opus-4-8"
);
let _guard =
kigi_env::EnvVarGuard::set(CLAUDE_OAUTH_BASE_URL_ENV, "https://mock.claude/v1");
assert_eq!(c.base_url(), "https://mock.claude/v1");
}
/// A variant missing from `ALL` compiles fine (`ALL`'s length is a plain /// A variant missing from `ALL` compiles fine (`ALL`'s length is a plain
/// literal) but is silently unparseable and excluded from model sync. /// literal) but is silently unparseable and excluded from model sync.
/// The exhaustive match below fails compilation when a variant is added, /// The exhaustive match below fails compilation when a variant is added,
@@ -1967,9 +2131,10 @@ mod tests {
PlatformId::Minimax => 23, PlatformId::Minimax => 23,
PlatformId::MinimaxCn => 24, PlatformId::MinimaxCn => 24,
PlatformId::XaiGrok => 25, PlatformId::XaiGrok => 25,
PlatformId::ClaudeProMax => 26,
} }
} }
const VARIANT_COUNT: usize = 26; // update together with `ordinal` const VARIANT_COUNT: usize = 27; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable(); seen.sort_unstable();
seen.dedup(); seen.dedup();
@@ -89,6 +89,7 @@ mod tests {
top_p: None, top_p: None,
api_backend: ApiBackend::ChatCompletions, api_backend: ApiBackend::ChatCompletions,
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 8192, context_window: 8192,
+198 -1
View File
@@ -41,6 +41,35 @@ pub use kigi_sampling_types::ApiBackend;
const AGENT_PRODUCT: &str = "kigi"; const AGENT_PRODUCT: &str = "kigi";
const ANTHROPIC_DEFAULT_MAX_TOKENS: u32 = 128_000; const ANTHROPIC_DEFAULT_MAX_TOKENS: u32 = 128_000;
/// Prepend the required Claude-Code system block to a Messages request's
/// `system` field (Claude Pro/Max OAuth path). Anthropic inspects the FIRST
/// system block, so the prefix is inserted as a distinct leading `text` block
/// while preserving any caller-supplied prompt (string or block form).
/// Idempotent: a leading block already equal to the prefix is not re-added.
fn prepend_claude_code_system_prefix(system: &mut Option<messages::SystemParam>) {
use messages::{SystemParam, TextBlock};
let text_block = |text: String| TextBlock {
r#type: "text".to_string(),
text,
cache_control: None,
};
let mut blocks = match system.take() {
None => Vec::new(),
Some(SystemParam::Text(text)) => vec![text_block(text)],
Some(SystemParam::Blocks(blocks)) => blocks,
};
let already_present = blocks
.first()
.is_some_and(|b| b.text == kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX);
if !already_present {
blocks.insert(
0,
text_block(kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX.to_string()),
);
}
*system = Some(SystemParam::Blocks(blocks));
}
/// Parse the `Retry-After` response header as delta-seconds. /// Parse the `Retry-After` response header as delta-seconds.
/// Our inference backends only emit integer seconds (never HTTP-date), /// Our inference backends only emit integer seconds (never HTTP-date),
/// so we only handle that form. HTTP-dates silently return `None` and /// so we only handle that form. HTTP-dates silently return `None` and
@@ -273,6 +302,8 @@ struct ClientDefaults {
chat_compat: kigi_sampling_types::ChatCompat, chat_compat: kigi_sampling_types::ChatCompat,
stream_tool_calls: bool, stream_tool_calls: bool,
doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>, doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>,
/// Claude Pro/Max OAuth Messages adaptation (see [`SamplerConfig`]).
anthropic_oauth: bool,
} }
// ============================================================================= // =============================================================================
@@ -398,6 +429,31 @@ impl SamplingClient {
} }
} }
// Claude Pro/Max OAuth identity headers (claude-pro-max only). The
// OAuth `sk-ant-oat…` bearer is Claude-Code-scoped, so Anthropic
// rejects the Messages request without the oauth beta + claude-cli
// identity. Gated on `anthropic_oauth` so API-key anthropic/minimax
// requests carry none of this and stay byte-identical. `Accept` is set
// per-request (text/event-stream for streams), so it is NOT added here.
if config.anthropic_oauth {
headers.insert(
HeaderName::from_static("anthropic-version"),
HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_VERSION),
);
headers.insert(
HeaderName::from_static("anthropic-beta"),
HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_OAUTH_BETA),
);
headers.insert(
HeaderName::from_static("x-app"),
HeaderValue::from_static("cli"),
);
headers.insert(
HeaderName::from_static("anthropic-dangerous-direct-browser-access"),
HeaderValue::from_static("true"),
);
}
// Apply all extra headers verbatim. This is the single // Apply all extra headers verbatim. This is the single
// injection point for proxy-auth headers and any other URL- or // injection point for proxy-auth headers and any other URL- or
// environment-specific headers the session decides to set. // environment-specific headers the session decides to set.
@@ -415,12 +471,18 @@ impl SamplingClient {
// (PRD F3: auth is a plain bearer; kimi-cli sends only User-Agent // (PRD F3: auth is a plain bearer; kimi-cli sends only User-Agent
// plus the OAuth device headers, src/kimi_cli/llm.py:317-323). // plus the OAuth device headers, src/kimi_cli/llm.py:317-323).
{ {
let ua_string = match config.origin_client.as_ref() { // Claude Pro/Max OAuth path presents the claude-cli identity;
// every other path keeps the kigi User-Agent.
let ua_string = if config.anthropic_oauth {
kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string()
} else {
match config.origin_client.as_ref() {
Some(origin) => user_agent_string_for(origin), Some(origin) => user_agent_string_for(origin),
None => user_agent_string_for(&OriginClientInfo { None => user_agent_string_for(&OriginClientInfo {
product: AGENT_PRODUCT.to_string(), product: AGENT_PRODUCT.to_string(),
version: Some(agent_version()), version: Some(agent_version()),
}), }),
}
}; };
if let Ok(v) = HeaderValue::from_str(&ua_string) { if let Ok(v) = HeaderValue::from_str(&ua_string) {
headers.insert(USER_AGENT, v); headers.insert(USER_AGENT, v);
@@ -460,6 +522,7 @@ impl SamplingClient {
chat_compat: config.chat_compat, chat_compat: config.chat_compat,
stream_tool_calls: config.stream_tool_calls, stream_tool_calls: config.stream_tool_calls,
doom_loop_recovery: config.doom_loop_recovery, doom_loop_recovery: config.doom_loop_recovery,
anthropic_oauth: config.anthropic_oauth,
}; };
Ok(Self { Ok(Self {
@@ -1344,6 +1407,15 @@ impl SamplingClient {
/// Apply default configuration to a Messages API request. /// Apply default configuration to a Messages API request.
fn apply_message_defaults(&self, request: &mut MessagesRequestWrapper) -> Result<()> { fn apply_message_defaults(&self, request: &mut MessagesRequestWrapper) -> Result<()> {
// Claude Pro/Max OAuth adaptation (claude-pro-max only): the OAuth
// token is Claude-Code-scoped, so the request MUST lead with the exact
// "You are Claude Code…" system block or Anthropic rejects it. Prepend
// it as a distinct first system block, preserving any caller prompt.
// Gated on `anthropic_oauth` so API-key anthropic/minimax are untouched.
if self.defaults.anthropic_oauth {
prepend_claude_code_system_prefix(&mut request.inner.system);
}
// Apply model default if not specified // Apply model default if not specified
if request.inner.model.is_empty() { if request.inner.model.is_empty() {
request.inner.model = self.defaults.model.clone(); request.inner.model = self.defaults.model.clone();
@@ -1888,6 +1960,7 @@ mod tests {
top_p: None, top_p: None,
api_backend: ApiBackend::ChatCompletions, api_backend: ApiBackend::ChatCompletions,
auth_scheme: AuthScheme::Bearer, auth_scheme: AuthScheme::Bearer,
anthropic_oauth: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 8192, context_window: 8192,
@@ -1942,6 +2015,130 @@ mod tests {
); );
} }
/// Claude Pro/Max OAuth Messages client (`anthropic_oauth = true`, Bearer,
/// Messages) carries the full OAuth identity: Bearer auth, anthropic-version,
/// the oauth `anthropic-beta`, `x-app: cli`, the claude-cli User-Agent, and
/// the direct-browser-access header.
#[test]
fn anthropic_oauth_messages_client_sends_oauth_identity_headers() {
let mut config = minimal_config();
config.api_key = Some("sk-ant-oat-secret".to_string());
config.auth_scheme = AuthScheme::Bearer;
config.api_backend = ApiBackend::Messages;
config.anthropic_oauth = true;
let client = SamplingClient::new(config).expect("client builds");
let h = &client.default_headers;
assert_eq!(
h.get(AUTHORIZATION).and_then(|v| v.to_str().ok()),
Some("Bearer sk-ant-oat-secret"),
"OAuth path rides Authorization: Bearer, never x-api-key"
);
assert!(
h.get("x-api-key").is_none(),
"OAuth path must not send x-api-key"
);
assert_eq!(
h.get("anthropic-version").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::ANTHROPIC_VERSION)
);
assert_eq!(
h.get("anthropic-beta").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::ANTHROPIC_OAUTH_BETA)
);
assert_eq!(h.get("x-app").and_then(|v| v.to_str().ok()), Some("cli"));
assert_eq!(
h.get(USER_AGENT).and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::CLAUDE_CODE_USER_AGENT)
);
assert_eq!(
h.get("anthropic-dangerous-direct-browser-access")
.and_then(|v| v.to_str().ok()),
Some("true")
);
}
/// REGRESSION: an API-key Anthropic Messages client (XApiKey, NOT oauth)
/// carries NONE of the OAuth identity — no anthropic-beta, no x-app, and
/// the kigi User-Agent — so the API-key path stays byte-identical.
#[test]
fn api_key_anthropic_messages_client_has_no_oauth_identity() {
let mut config = minimal_config();
config.auth_scheme = AuthScheme::XApiKey;
config.api_backend = ApiBackend::Messages;
// anthropic_oauth stays false.
let client = SamplingClient::new(config).expect("client builds");
let h = &client.default_headers;
assert!(
h.get("anthropic-beta").is_none(),
"API-key anthropic must NOT send the oauth beta"
);
assert!(
h.get("x-app").is_none(),
"API-key anthropic must NOT send x-app"
);
assert!(h.get("anthropic-dangerous-direct-browser-access").is_none());
assert!(
h.get(USER_AGENT)
.and_then(|v| v.to_str().ok())
.is_some_and(|ua| ua.starts_with("kigi/")),
"API-key anthropic keeps the kigi User-Agent"
);
}
/// 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).
#[test]
fn claude_code_system_prefix_prepends_and_is_idempotent() {
use messages::{SystemParam, TextBlock};
let prefix = kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX;
// Absent system → a single prefix block.
let mut none = None;
prepend_claude_code_system_prefix(&mut none);
match none {
Some(SystemParam::Blocks(b)) => {
assert_eq!(b.len(), 1);
assert_eq!(b[0].text, prefix);
}
other => panic!("expected one prefix block, got {other:?}"),
}
// String system → [prefix, original].
let mut text = Some(SystemParam::Text("do the thing".into()));
prepend_claude_code_system_prefix(&mut text);
match text {
Some(SystemParam::Blocks(b)) => {
assert_eq!(b.len(), 2);
assert_eq!(b[0].text, prefix);
assert_eq!(b[1].text, "do the thing");
}
other => panic!("expected two blocks, got {other:?}"),
}
// Idempotent: a leading prefix block is not re-added.
let mut already = Some(SystemParam::Blocks(vec![
TextBlock {
r#type: "text".into(),
text: prefix.to_string(),
cache_control: None,
},
TextBlock {
r#type: "text".into(),
text: "tail".into(),
cache_control: None,
},
]));
prepend_claude_code_system_prefix(&mut already);
match already {
Some(SystemParam::Blocks(b)) => {
assert_eq!(b.len(), 2, "prefix must not be duplicated");
assert_eq!(b[0].text, prefix);
}
other => panic!("expected unchanged blocks, got {other:?}"),
}
}
/// Verify the serialized shape of StreamingChatRequest matches the /// Verify the serialized shape of StreamingChatRequest matches the
/// expected wire format: all ChatCompletionRequest fields flattened at /// expected wire format: all ChatCompletionRequest fields flattened at
/// top level, plus `stream: true` and `stream_options.include_usage: true`. /// top level, plus `stream: true` and `stream_options.include_usage: true`.
@@ -55,6 +55,13 @@ pub struct SamplerConfig {
pub api_backend: ApiBackend, pub api_backend: ApiBackend,
#[serde(default)] #[serde(default)]
pub auth_scheme: AuthScheme, pub auth_scheme: AuthScheme,
/// Claude Pro/Max OAuth adaptation (claude-pro-max only). When true the
/// Messages request carries the OAuth identity headers (`anthropic-beta`
/// oauth, `claude-cli` User-Agent, `x-app: cli`) and its system prompt is
/// prefixed with the required "You are Claude Code…" line. Gated so the
/// API-key `anthropic` + `minimax` Messages requests stay byte-identical.
#[serde(default)]
pub anthropic_oauth: bool,
/// Extra request headers applied verbatim. The sampler never inspects /// Extra request headers applied verbatim. The sampler never inspects
/// the URL to derive headers; callers (the session) inject proxy auth /// the URL to derive headers; callers (the session) inject proxy auth
/// and other access headers here before constructing the config. /// and other access headers here before constructing the config.
@@ -144,6 +151,7 @@ impl Default for SamplerConfig {
top_p: None, top_p: None,
api_backend: ApiBackend::default(), api_backend: ApiBackend::default(),
auth_scheme: AuthScheme::default(), auth_scheme: AuthScheme::default(),
anthropic_oauth: false,
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 0, context_window: 0,
force_http1: false, force_http1: false,
@@ -78,6 +78,7 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig {
top_p: None, top_p: None,
api_backend: ApiBackend::ChatCompletions, api_backend: ApiBackend::ChatCompletions,
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: IndexMap::new(), extra_headers: IndexMap::new(),
context_window: 128_000, context_window: 128_000,
@@ -1060,6 +1060,22 @@ pub fn normalize_effort_echo(value: &mut Value) {
/// wires (Messages inference and the /v1/models listing). /// wires (Messages inference and the /v1/models listing).
pub const ANTHROPIC_VERSION: &str = "2023-06-01"; pub const ANTHROPIC_VERSION: &str = "2023-06-01";
/// `anthropic-beta` value for the Claude-Code OAuth (Pro/Max subscription)
/// path — required on BOTH the Messages inference request and the `/v1/models`
/// listing when the bearer is an OAuth `sk-ant-oat…` token. API-key Anthropic
/// and MiniMax NEVER send this (their requests stay byte-identical).
pub const ANTHROPIC_OAUTH_BETA: &str = "claude-code-20250219,oauth-2025-04-20";
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
/// official Claude Code CLI). OAuth-gated: unrelated to the default kigi UA.
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75";
/// System-prompt prefix REQUIRED on the Claude-Code OAuth Messages path: the
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
/// the system prompt's first block is exactly this line. OAuth-gated.
pub const CLAUDE_CODE_SYSTEM_PREFIX: &str =
"You are Claude Code, Anthropic's official CLI for Claude.";
/// ChatCompletions request-body adaptation dialect. Providers disagree on /// ChatCompletions request-body adaptation dialect. Providers disagree on
/// how thinking rides an OpenAI-compatible body: Kimi wants /// how thinking rides an OpenAI-compatible body: Kimi wants
/// `thinking:{type,effort}`, DeepSeek wants /// `thinking:{type,effort}`, DeepSeek wants
@@ -595,6 +595,11 @@ mod tests {
); );
assert_eq!( assert_eq!(
ids[kimi_pos + 2], ids[kimi_pos + 2],
"claude-pro-max",
"claude-pro-max is the next interactive OAuth login, after xai-grok"
);
assert_eq!(
ids[kimi_pos + 3],
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
"the api-key rows follow the generic oauth logins" "the api-key rows follow the generic oauth logins"
); );
@@ -628,6 +633,29 @@ mod tests {
assert_eq!(kind.auth_error_message(), AUTH_ERROR_SESSION_EXPIRED); assert_eq!(kind.auth_error_message(), AUTH_ERROR_SESSION_EXPIRED);
} }
/// claude-pro-max classifies as an interactive OAuth login too (the
/// authenticate handler dispatches it to the PKCE-localhost flow by the
/// config's `flow`): session-based, needs a browser, never api-key, and
/// `oauth_platform()` returns ClaudeProMax.
#[test]
fn claude_pro_max_is_an_interactive_oauth_login() {
let id = acp::AuthMethodId::new("claude-pro-max");
let kind = AuthMethodKind::from_id(&id);
assert_eq!(
kind,
AuthMethodKind::OAuthPlatform(kigi_models::PlatformId::ClaudeProMax)
);
assert!(kind.needs_interactive_login());
assert!(kind.is_session_based());
assert!(!kind.is_api_key());
assert_eq!(
kind.oauth_platform(),
Some(kigi_models::PlatformId::ClaudeProMax)
);
// Never an API-key picker target (keeps it out of the paste-box path).
assert_eq!(platform_for_method_id(&id), None);
}
/// The OAuth platform id must never resolve as an API-key platform /// The OAuth platform id must never resolve as an API-key platform
/// method — `platform_for_method_id`'s `uses_oauth` filter is what keeps /// method — `platform_for_method_id`'s `uses_oauth` filter is what keeps
/// the generic `authenticate` arm from hijacking the device login. /// the generic `authenticate` arm from hijacking the device login.
@@ -721,6 +749,7 @@ mod tests {
XAI_API_KEY_METHOD_ID, XAI_API_KEY_METHOD_ID,
KIMI_CODE_METHOD_ID, KIMI_CODE_METHOD_ID,
"xai-grok", "xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -770,6 +799,7 @@ mod tests {
CACHED_TOKEN_AUTH_METHOD_ID, CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID, KIMI_CODE_METHOD_ID,
"xai-grok", "xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -812,6 +842,7 @@ mod tests {
CACHED_TOKEN_AUTH_METHOD_ID, CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID, KIMI_CODE_METHOD_ID,
"xai-grok", "xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -857,6 +888,7 @@ mod tests {
vec![ vec![
KIMI_CODE_METHOD_ID, KIMI_CODE_METHOD_ID,
"xai-grok", "xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID, MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID, MOONSHOT_AI_METHOD_ID,
"openai", "openai",
@@ -4079,6 +4079,18 @@ pub fn sampling_config_for_model(
} }
}) })
.unwrap_or_default(); .unwrap_or_default();
// Claude Pro/Max OAuth Messages adaptation: a managed key whose platform is
// a generic-OAuth Messages provider (claude-pro-max) drives the OAuth
// identity headers + "You are Claude Code" system prefix in the sampler.
// Gated here so API-key anthropic/minimax (oauth None) stay byte-identical.
let anthropic_oauth = info
.id
.as_deref()
.and_then(kigi_models::parse_managed_model_key)
.is_some_and(|(platform, _)| {
platform.oauth().is_some()
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
});
SamplerConfig { SamplerConfig {
api_key: credentials.api_key, api_key: credentials.api_key,
model: model_name, model: model_name,
@@ -4088,6 +4100,7 @@ pub fn sampling_config_for_model(
top_p, top_p,
api_backend, api_backend,
auth_scheme: credentials.auth_scheme, auth_scheme: credentials.auth_scheme,
anthropic_oauth,
chat_compat, chat_compat,
extra_headers, extra_headers,
context_window: info.context_window.get(), context_window: info.context_window.get(),
@@ -345,9 +345,22 @@ fn fetch_one_platform_models(
}; };
tracing::info!(platform = platform.as_str(), url = %url, "fetching platform models"); tracing::info!(platform = platform.as_str(), url = %url, "fetching platform models");
let request = match platform.key_header() { let request = match platform.key_header() {
kigi_models::PlatformKeyHeader::Bearer => client kigi_models::PlatformKeyHeader::Bearer => {
let mut req = client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", bearer)), .header("Authorization", format!("Bearer {}", bearer));
// An Anthropic listing reached with a Bearer key is the OAuth
// channel (claude-pro-max): the /v1/models endpoint requires
// anthropic-version, and the OAuth bearer needs the oauth beta.
// The OpenAI-listing Bearer platforms (xai-grok, api-key OpenAI
// rows) add neither, so their requests stay byte-identical.
if platform.listing() == kigi_models::ListingDialect::Anthropic {
req = req
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION)
.header("anthropic-beta", kigi_sampling_types::ANTHROPIC_OAUTH_BETA);
}
req
}
kigi_models::PlatformKeyHeader::XApiKey => client kigi_models::PlatformKeyHeader::XApiKey => client
.get(&url) .get(&url)
.header("x-api-key", bearer) .header("x-api-key", bearer)
@@ -1106,6 +1119,118 @@ mod tests {
); );
} }
/// Claude Pro/Max OAuth fetch e2e (mock wire): `GET /v1/models?limit=1000`
/// gated on the OAuth `Authorization: Bearer` + the oauth `anthropic-beta`
/// (the OAuth listing contract) → anthropic listing → enriched from
/// models.dev "anthropic" → keyed `claude-pro-max/<id>` on the Messages
/// backend. The token is drawn from the claude-pro-max OAuth-session map,
/// NOT an x-api-key.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn claude_pro_max_oauth_listing_is_bearer_gated_and_keyed() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::query_param("limit", "1000"))
// OAuth listing: Bearer token + the oauth beta + anthropic-version.
// NO x-api-key header (that is the API-key `anthropic` path).
.and(wiremock::matchers::header(
"Authorization",
"Bearer sk-ant-oat-session",
))
// The oauth beta is comma-joined; wiremock's exact `header` matcher
// splits on commas, so assert both tokens via the multi-valued form.
.and(wiremock::matchers::headers(
"anthropic-beta",
vec!["claude-code-20250219", "oauth-2025-04-20"],
))
.and(wiremock::matchers::header(
"anthropic-version",
"2023-06-01",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{
"id": "claude-opus-4-8",
"display_name": "Claude Opus 4.8",
"type": "model"
}
], "has_more": false }),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "anthropic": { "models": {
"claude-opus-4-8": {
"limit": {"context": 1000000, "output": 128000},
"tool_call": true
}
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::CLAUDE_OAUTH_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
// The listing bearer comes from the claude-pro-max OAuth-session map,
// never a Kimi session (auth=None) or an API key (keys empty).
let mut oauth_tokens = OAuthSessionTokens::new();
oauth_tokens.insert(
kigi_models::PlatformId::ClaudeProMax,
"sk-ant-oat-session".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("claude-pro-max oauth fetch must succeed");
assert_eq!(result.models.len(), 1);
let opus = &result.models[0];
assert_eq!(
opus.id.as_deref(),
Some("claude-pro-max/claude-opus-4-8"),
"the entry must key under the claude-pro-max platform"
);
assert_eq!(
opus.api_backend,
crate::sampling::ApiBackend::Messages,
"claude-pro-max speaks the Messages wire"
);
assert_eq!(
opus.auth_scheme, None,
"OAuth Bearer entries carry no XApiKey auth scheme"
);
assert_eq!(
opus.context_window.get(),
1_000_000,
"enrichment fills the context window from models.dev anthropic"
);
assert!(
!opus.supported_in_api,
"subscription (uses_oauth) models require the OAuth session"
);
}
/// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts /// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts
/// (high/max) produce ChatCompletions entries whose sampler config /// (high/max) produce ChatCompletions entries whose sampler config
/// speaks the DeepSeek thinking dialect. /// speaks the DeepSeek thinking dialect.
@@ -880,6 +880,14 @@ async fn read_parent_sampling_config(
let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None) let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
.map(|r| r.auth_scheme) .map(|r| r.auth_scheme)
.unwrap_or_default(); .unwrap_or_default();
// Claude Pro/Max OAuth Messages adaptation inherits from the parent
// model's platform (claude-pro-max → true); every other platform,
// and BYOK, → false, so the API-key paths stay byte-identical.
let anthropic_oauth = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
.is_some_and(|(platform, _)| {
platform.oauth().is_some()
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
});
let inherited = kigi_sampler::SamplerConfig { let inherited = kigi_sampler::SamplerConfig {
api_key: creds.api_key, api_key: creds.api_key,
base_url: cfg.base_url, base_url: cfg.base_url,
@@ -889,6 +897,7 @@ async fn read_parent_sampling_config(
top_p: cfg.top_p, top_p: cfg.top_p,
api_backend: cfg.api_backend, api_backend: cfg.api_backend,
auth_scheme, auth_scheme,
anthropic_oauth,
chat_compat: cfg.chat_compat, chat_compat: cfg.chat_compat,
extra_headers, extra_headers,
context_window: cfg.context_window.get(), context_window: cfg.context_window.get(),
@@ -198,8 +198,8 @@ async fn complete_device_code_login(
/// Open `url` in the browser off-thread: `webbrowser::open` is synchronous and /// Open `url` in the browser off-thread: `webbrowser::open` is synchronous and
/// would stall the single-threaded TUI loop. Returns `true` on success so the /// would stall the single-threaded TUI loop. Returns `true` on success so the
/// caller can decide how to notify the user (eprintln on CLI, nothing on TUI /// caller can decide how to notify the user (eprintln on CLI, nothing on TUI
/// where the URL is already rendered in the widget). /// where the URL is already rendered in the widget). Shared with the PKCE flow.
async fn open_browser_detached(url: &str) -> bool { pub(super) async fn open_browser_detached(url: &str) -> bool {
// Unit tests drive the full login flow against mock servers — their // Unit tests drive the full login flow against mock servers — their
// fixture URLs must never reach a real browser. // fixture URLs must never reach a real browser.
if cfg!(test) { if cfg!(test) {
+93 -4
View File
@@ -67,10 +67,11 @@ pub async fn run_auth_flow(
run_auth_flow_inner(auth_manager, kimi_code_config, reauth, false, channels).await run_auth_flow_inner(auth_manager, kimi_code_config, reauth, false, channels).await
} }
/// Login flow for a GENERIC device-code OAuth provider (xai-grok): use a valid /// Login flow for a GENERIC OAuth provider (xai-grok device-code,
/// cached session unless re-authing, otherwise run the generic device flow /// claude-pro-max PKCE-localhost): use a valid cached session unless re-authing,
/// otherwise dispatch by `oauth.flow` to the device-code or PKCE-localhost login
/// (persisting under the provider's own scope via `auth_manager`). Unlike the /// (persisting under the provider's own scope via `auth_manager`). Unlike the
/// Kimi flow this does not run the silent-refresh dance — the device flow's /// Kimi flow this does not run the silent-refresh dance — the login's
/// `AuthManager::update` persists a fresh token set directly. /// `AuthManager::update` persists a fresh token set directly.
pub async fn run_oauth_provider_flow( pub async fn run_oauth_provider_flow(
auth_manager: &Arc<AuthManager>, auth_manager: &Arc<AuthManager>,
@@ -94,8 +95,96 @@ pub async fn run_oauth_provider_flow(
return Ok((auth, false)); return Ok((auth, false));
} }
let mut channels = channels; let mut channels = channels;
crate::auth::device_code::run_device_code_login_generic(oauth, auth_manager, &mut channels) match oauth.flow {
kigi_models::OAuthFlow::DeviceCode => {
crate::auth::device_code::run_device_code_login_generic(
oauth,
auth_manager,
&mut channels,
)
.await .await
}
kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => {
run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await
}
}
}
/// 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.
///
/// SECURITY: the verifier / code / tokens 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,
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);
let authorize_url = oauth_pkce::build_authorize_url(oauth, &redirect, &pkce);
let mut chans = channels.take();
if let Some(tx) = chans.as_mut().and_then(|c| c.url_tx.take()) {
// TUI: push the URL BEFORE opening the browser (never block the UI on a
// slow/headless browser launch).
let _ = tx.send(AuthUrlInfo {
url: authorize_url.clone(),
mode: AuthUrlMode::Device,
});
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!();
eprintln!(" {authorize_url}");
eprintln!();
if !crate::auth::device_code::open_browser_detached(&authorize_url).await {
eprintln!(" (Could not open the browser automatically — open the URL above.)");
eprintln!();
}
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 auth = auth_manager
.update(auth)
.await
.map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?;
Ok((auth, true))
}
/// Await the authorization code: the loopback callback is primary; when a TUI
/// channel is present, a pasted code (redirect URL / `code#state` / bare code)
/// is accepted concurrently as a headless fallback. State is validated in both
/// arms (strict on the loopback, mismatch-rejecting on the paste).
async fn await_pkce_code(
redirect_port: u16,
pkce: &crate::auth::oauth_pkce::PkceCodes,
channels: Option<&mut AuthChannels>,
) -> anyhow::Result<String> {
use crate::auth::oauth_pkce;
match channels {
Some(ch) => {
tokio::select! {
code = oauth_pkce::await_loopback_code(redirect_port, &pkce.state) => code,
pasted = ch.code_rx.recv() => {
let pasted = pasted
.ok_or_else(|| anyhow::anyhow!("auth code channel closed before a code arrived"))?;
let params = oauth_pkce::parse_manual_paste(&pasted)?;
oauth_pkce::validate_pasted_state(&params, &pkce.state)?;
Ok(params.code)
}
}
}
None => oauth_pkce::await_loopback_code(redirect_port, &pkce.state).await,
}
} }
async fn run_auth_flow_inner( async fn run_auth_flow_inner(
@@ -9,6 +9,7 @@ pub(crate) mod kimi_oauth;
pub(crate) mod manager; pub(crate) mod manager;
mod model; mod model;
pub(crate) mod oauth_device; pub(crate) mod oauth_device;
pub(crate) mod oauth_pkce;
pub(crate) mod oauth_registry; pub(crate) mod oauth_registry;
pub(crate) mod recovery; pub(crate) mod recovery;
pub(crate) mod refresh; pub(crate) mod refresh;
@@ -0,0 +1,687 @@
//! 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).
//!
//! Shape (Pi `earendil-works/pi` `auth/oauth/anthropic.ts`):
//! - `verifier = base64url(32 random bytes)`; `challenge = base64url(SHA-256(
//! verifier))`; `state = verifier`.
//! - Browser opens `{auth_host}{device_path}?client_id&response_type=code&
//! scope&redirect_uri&state&code_challenge&code_challenge_method=S256`.
//! - 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.
//!
//! SECURITY: the verifier, authorization code, access token, and refresh token
//! are NEVER logged (only non-secret events: authorize URL requested, callback
//! received, token issued, token refreshed).
use anyhow::Context;
use base64::Engine;
use kigi_models::{OAuthConfig, OAuthTokenBody};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use super::kimi_oauth::{RefreshError, TokenResponse};
use super::model::KimiAuth;
const CODE_GRANT_TYPE: &str = "authorization_code";
const REFRESH_GRANT_TYPE: &str = "refresh_token";
/// Refresh retry budget over the retryable statuses / network blips.
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).
#[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).
pub state: String,
}
/// Generate PKCE S256 codes: `verifier = base64url(32 random bytes)`,
/// `challenge = base64url(SHA-256(verifier))`, `state = verifier`.
pub(crate) fn generate_pkce() -> PkceCodes {
use rand::RngCore;
let mut raw = [0u8; 32];
rand::rng().fill_bytes(&mut raw);
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
let digest = Sha256::digest(verifier.as_bytes());
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
PkceCodes {
state: verifier.clone(),
verifier,
challenge,
}
}
/// The loopback redirect URI for a PKCE-localhost provider.
pub(crate) fn redirect_uri(redirect_port: u16) -> String {
format!("http://localhost:{redirect_port}/callback")
}
/// Build the browser authorize URL:
/// `{auth_host}{device_path}?client_id&response_type=code&scope&redirect_uri&
/// state&code_challenge&code_challenge_method=S256`.
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())
.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}")
}
/// `code` + `state` extracted from a callback (loopback query OR manual paste).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CallbackParams {
pub code: String,
pub state: Option<String>,
}
/// Parse `code`/`state` from the raw query string of a `/callback?…` request
/// (e.g. `code=abc&state=xyz`). An `error=` param surfaces as an `Err`.
pub(crate) fn parse_callback_query(query: &str) -> anyhow::Result<CallbackParams> {
let mut code = None;
let mut state = None;
let mut error = None;
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
match k.as_ref() {
"code" => code = Some(v.into_owned()),
"state" => state = Some(v.into_owned()),
"error" => error = Some(v.into_owned()),
_ => {}
}
}
if let Some(error) = error {
anyhow::bail!("Authorization server returned an error: {error}");
}
let code = code.context("callback missing authorization code")?;
if code.is_empty() {
anyhow::bail!("callback authorization code was empty");
}
Ok(CallbackParams { code, state })
}
/// Parse a MANUAL paste (headless fallback). Accepts, in order:
/// - a full redirect URL (`http://localhost:…/callback?code=…&state=…`),
/// - a `code#state` pair (Anthropic's console shows this form),
/// - a bare `code` (state then unknown → `None`, caller validation applies).
pub(crate) fn parse_manual_paste(input: &str) -> anyhow::Result<CallbackParams> {
let trimmed = input.trim();
if trimmed.is_empty() {
anyhow::bail!("empty paste");
}
// Full redirect URL.
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
let url = url::Url::parse(trimmed).context("pasted value is not a valid URL")?;
return parse_callback_query(url.query().unwrap_or_default());
}
// `code#state`.
if let Some((code, state)) = trimmed.split_once('#') {
if code.is_empty() {
anyhow::bail!("pasted code was empty");
}
return Ok(CallbackParams {
code: code.to_owned(),
state: (!state.is_empty()).then(|| state.to_owned()),
});
}
// Bare code.
Ok(CallbackParams {
code: trimmed.to_owned(),
state: None,
})
}
/// STRICT state validation (CSRF guard): the callback `state` MUST be present
/// AND equal to the expected value. A mismatch (or absence, when a paste has no
/// state) is rejected — the flow NEVER proceeds on an unverified callback.
pub(crate) fn validate_state(params: &CallbackParams, expected_state: &str) -> anyhow::Result<()> {
match params.state.as_deref() {
Some(state) if state == expected_state => Ok(()),
Some(_) => anyhow::bail!("OAuth state mismatch — rejecting callback (CSRF guard)"),
None => anyhow::bail!("OAuth callback carried no state — rejecting (CSRF guard)"),
}
}
/// State validation for a MANUAL paste (headless fallback): a present state
/// MUST match (mismatch rejected — CSRF guard), but an ABSENT state is allowed
/// — a bare-code paste is user-initiated (not a network-reachable callback), so
/// there is no state to check. The loopback path uses the stricter
/// [`validate_state`] (an absent state there IS rejected).
pub(crate) fn validate_pasted_state(
params: &CallbackParams,
expected_state: &str,
) -> anyhow::Result<()> {
match params.state.as_deref() {
Some(state) if state == expected_state => Ok(()),
Some(_) => anyhow::bail!("OAuth state mismatch — rejecting pasted code (CSRF guard)"),
None => Ok(()),
}
}
/// The token-endpoint URL (`{token_host}{token_path}`).
fn token_url(cfg: &OAuthConfig) -> String {
format!("{}{}", cfg.token_host.trim_end_matches('/'), cfg.token_path)
}
/// POST the token endpoint with a JSON body, honoring `cfg.token_body`. Claude
/// is JSON; a `Form`-bodied config would be handled by the device wire, so the
/// PKCE path asserts JSON (never silently mis-encodes).
async fn post_token_json(
cfg: &OAuthConfig,
body: serde_json::Value,
) -> reqwest::Result<reqwest::Response> {
debug_assert!(
matches!(cfg.token_body, OAuthTokenBody::Json),
"PKCE token exchange expects a JSON token body"
);
crate::http::shared_client()
.post(token_url(cfg))
.header("Accept", "application/json")
.json(&body)
.send()
.await
}
/// Exchange an authorization `code` for a token set (JSON body):
/// `{grant_type:"authorization_code", code, state, client_id, redirect_uri,
/// code_verifier}`. Returns the materialized [`KimiAuth`].
pub(crate) async fn exchange_code(
cfg: &OAuthConfig,
code: &str,
pkce: &PkceCodes,
redirect_uri: &str,
) -> anyhow::Result<KimiAuth> {
let body = serde_json::json!({
"grant_type": CODE_GRANT_TYPE,
"code": code,
"state": pkce.state,
"client_id": cfg.client_id,
"redirect_uri": redirect_uri,
"code_verifier": pkce.verifier,
});
tracing::info!(
scope_key = cfg.scope_key,
"auth: exchanging code for token (pkce)"
);
let resp = post_token_json(cfg, body)
.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)");
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 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
/// backoff; 401/403 returns immediately as [`RefreshError::Unauthorized`].
pub(crate) async fn refresh_token(
cfg: &OAuthConfig,
refresh_token: &str,
) -> Result<KimiAuth, RefreshError> {
let mut last_error = String::from("no attempt made");
for attempt in 0..MAX_REFRESH_RETRIES {
if attempt > 0 {
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
last_error = %last_error,
"auth: retrying token refresh (pkce)"
);
tokio::time::sleep(backoff).await;
}
tracing::info!(
attempt,
scope_key = cfg.scope_key,
"auth: token refresh attempt (pkce)"
);
let body = serde_json::json!({
"grant_type": REFRESH_GRANT_TYPE,
"client_id": cfg.client_id,
"refresh_token": refresh_token,
});
let resp = match post_token_json(cfg, body).await {
Ok(resp) => resp,
Err(e) => {
last_error = format!("network error: {e}");
continue;
}
};
let status = resp.status().as_u16();
let bytes = resp.bytes().await.unwrap_or_default();
if status == 401 || status == 403 {
let err: OAuthErrorBody = serde_json::from_slice(&bytes).unwrap_or_default();
return Err(RefreshError::Unauthorized {
status,
description: err
.error_description
.unwrap_or_else(|| "Token refresh unauthorized.".to_owned()),
});
}
if status == 200 {
return match serde_json::from_slice::<TokenResponse>(&bytes) {
Ok(tokens) => Ok(tokens.into_auth()),
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("malformed token payload: {e}"),
}),
};
}
let err: OAuthErrorBody = serde_json::from_slice(&bytes).unwrap_or_default();
let description = err
.error_description
.unwrap_or_else(|| format!("Token refresh failed (HTTP {status})."));
if RETRYABLE_REFRESH_STATUSES.contains(&status) {
last_error = description;
continue;
}
return Err(RefreshError::Fatal {
status,
description,
});
}
Err(RefreshError::Exhausted { last_error })
}
#[derive(Deserialize, Default)]
struct OAuthErrorBody {
#[serde(default)]
error_description: Option<String>,
}
/// 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.
///
/// 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.
pub(crate) async fn await_loopback_code(
redirect_port: u16,
expected_state: &str,
) -> anyhow::Result<String> {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", redirect_port))
.await
.with_context(|| format!("could not bind loopback 127.0.0.1:{redirect_port}"))?;
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 {
LoopbackOutcome::Code(code) => return Ok(code),
LoopbackOutcome::Rejected(err) => return Err(err),
// Not the /callback GET (favicon, health probe): keep listening.
LoopbackOutcome::Ignore => continue,
}
}
}
enum LoopbackOutcome {
Code(String),
Rejected(anyhow::Error),
Ignore,
}
/// Read the request line of one loopback connection, answer with a small HTML
/// page, and classify the outcome. STRICT: a `/callback` with a bad/missing
/// state is [`LoopbackOutcome::Rejected`] (the browser sees an error page).
async fn handle_loopback_conn(
mut stream: tokio::net::TcpStream,
expected_state: &str,
) -> LoopbackOutcome {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Read only enough for the request line — a GET has no body.
let mut buf = [0u8; 8192];
let n = match stream.read(&mut buf).await {
Ok(0) => return LoopbackOutcome::Ignore,
Ok(n) => n,
Err(_) => return LoopbackOutcome::Ignore,
};
let head = String::from_utf8_lossy(&buf[..n]);
let Some(request_line) = head.lines().next() else {
return LoopbackOutcome::Ignore;
};
// `GET /callback?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;
};
if method != "GET" {
let _ = write_http(&mut stream, 405, "Method Not Allowed").await;
return LoopbackOutcome::Ignore;
}
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if path != "/callback" {
let _ = write_http(&mut stream, 404, "Not Found").await;
return LoopbackOutcome::Ignore;
}
let result = parse_callback_query(query)
.and_then(|params| validate_state(&params, expected_state).map(|()| params.code));
match result {
Ok(code) => {
let _ = write_http(
&mut stream,
200,
"Signed in to Claude Pro/Max. You can close this window and return to kigi.",
)
.await;
let _ = stream.flush().await;
LoopbackOutcome::Code(code)
}
Err(e) => {
let _ = write_http(&mut stream, 400, "Login failed — return to kigi and retry.").await;
let _ = stream.flush().await;
LoopbackOutcome::Rejected(e)
}
}
}
/// Write a minimal HTTP/1.1 response with an HTML body.
async fn write_http(
stream: &mut tokio::net::TcpStream,
status: u16,
message: &str,
) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
let body = format!("<!doctype html><meta charset=utf-8><p>{message}</p>");
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::CLAUDE_OAUTH_CONFIG;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A config pointed at a mock token host (copies Claude's client_id/scope/
/// paths but overrides the token host).
fn mock_cfg(token_host: &'static str) -> OAuthConfig {
OAuthConfig {
token_host,
..CLAUDE_OAUTH_CONFIG
}
}
/// PKCE codes: verifier/challenge are non-empty base64url (no padding), the
/// challenge is the base64url SHA-256 of the verifier, and state == verifier.
#[test]
fn generate_pkce_produces_valid_s256_codes() {
let pkce = generate_pkce();
assert_eq!(pkce.state, pkce.verifier, "state must equal the verifier");
assert!(!pkce.verifier.is_empty() && !pkce.challenge.is_empty());
for s in [&pkce.verifier, &pkce.challenge] {
assert!(
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"base64url (no pad) only: {s}"
);
assert!(!s.contains('='), "no padding: {s}");
}
// challenge == base64url(SHA-256(verifier)).
let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(pkce.verifier.as_bytes()));
assert_eq!(pkce.challenge, expect);
// Fresh entropy each call.
assert_ne!(pkce.verifier, generate_pkce().verifier);
}
/// The authorize URL carries the fixed params + the PKCE state and S256
/// challenge, and targets `claude.ai/oauth/authorize`.
#[test]
fn authorize_url_has_state_and_s256_challenge() {
let pkce = generate_pkce();
let redirect = redirect_uri(53692);
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"));
assert_eq!(parsed.path(), "/oauth/authorize");
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
assert_eq!(q.get("response_type").map(String::as_str), Some("code"));
assert_eq!(
q.get("code_challenge_method").map(String::as_str),
Some("S256")
);
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("client_id").map(String::as_str),
Some(CLAUDE_OAUTH_CONFIG.client_id)
);
assert_eq!(
q.get("redirect_uri").map(String::as_str),
Some(redirect.as_str())
);
// The verifier itself must NEVER appear in the browser URL.
assert!(
!url.contains("code_verifier"),
"the verifier must not ride the authorize URL"
);
}
/// STRICT state validation: an exact match passes; a mismatch or an absent
/// state is REJECTED (CSRF guard — the flow must never proceed).
#[test]
fn state_validation_is_strict() {
let ok = CallbackParams {
code: "c".into(),
state: Some("expected".into()),
};
assert!(validate_state(&ok, "expected").is_ok());
let mismatch = CallbackParams {
code: "c".into(),
state: Some("attacker".into()),
};
assert!(
validate_state(&mismatch, "expected").is_err(),
"a state mismatch MUST be rejected"
);
let missing = CallbackParams {
code: "c".into(),
state: None,
};
assert!(
validate_state(&missing, "expected").is_err(),
"an absent state MUST be rejected"
);
}
/// A loopback `/callback` with the WRONG state is rejected end-to-end (the
/// listener returns an error, never a code) — the CSRF guard on the wire.
#[tokio::test]
async fn loopback_rejects_state_mismatch() {
// Ephemeral port: bind, learn the port, then drive a client at it.
let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let server = tokio::spawn(async move { await_loopback_code(port, "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.
let _ = reqwest::get(format!(
"http://127.0.0.1:{port}/callback?code=stolen&state=wrong-state"
))
.await;
let outcome = server.await.unwrap();
let err = outcome.expect_err("a state mismatch must be rejected, never yield a code");
assert!(err.to_string().contains("state mismatch"), "{err}");
}
/// A loopback `/callback` with the MATCHING state yields the code.
#[tokio::test]
async fn loopback_returns_code_on_valid_state() {
let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let server = tokio::spawn(async move { await_loopback_code(port, "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"
))
.await;
let code = server.await.unwrap().expect("valid state yields the code");
assert_eq!(code, "auth-code-123");
}
/// Manual-paste parsing: full redirect URL, `code#state`, and bare code.
#[test]
fn manual_paste_parses_all_three_forms() {
let from_url =
parse_manual_paste("http://localhost:53692/callback?code=abc123&state=st-9").unwrap();
assert_eq!(from_url.code, "abc123");
assert_eq!(from_url.state.as_deref(), Some("st-9"));
let from_hash = parse_manual_paste("abc123#st-9").unwrap();
assert_eq!(from_hash.code, "abc123");
assert_eq!(from_hash.state.as_deref(), Some("st-9"));
let bare = parse_manual_paste(" abc123 ").unwrap();
assert_eq!(bare.code, "abc123");
assert_eq!(bare.state, None);
assert!(parse_manual_paste("").is_err());
// A pasted redirect that carries an error param surfaces the error.
assert!(parse_manual_paste("http://localhost/callback?error=access_denied").is_err());
}
/// Code → token exchange: JSON body carries the grant + verifier, response
/// materializes a `KimiAuth` with the rotating refresh token.
#[tokio::test]
async fn exchange_code_posts_json_and_returns_auth() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/v1/oauth/token"))
.and(body_string_contains(
"\"grant_type\":\"authorization_code\"",
))
.and(body_string_contains("\"code\":\"auth-code-xyz\""))
.and(body_string_contains("\"code_verifier\""))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "sk-ant-oat-new",
"refresh_token": "sk-ant-ort-new",
"expires_in": 3600,
"token_type": "bearer",
})))
.expect(1)
.mount(&server)
.await;
let cfg = mock_cfg(host);
let pkce = generate_pkce();
let auth = exchange_code(&cfg, "auth-code-xyz", &pkce, &redirect_uri(53692))
.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));
}
/// Refresh rotates the refresh token (JSON body, refresh grant).
#[tokio::test]
async fn refresh_rotates_refresh_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/v1/oauth/token"))
.and(body_string_contains("\"grant_type\":\"refresh_token\""))
.and(body_string_contains("\"refresh_token\":\"ort-old\""))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "oat-fresh",
"refresh_token": "ort-rotated",
"expires_in": 3600,
})))
.expect(1)
.mount(&server)
.await;
let auth = refresh_token(&mock_cfg(host), "ort-old").await.unwrap();
assert_eq!(auth.key, "oat-fresh");
assert_eq!(
auth.refresh_token.as_deref(),
Some("ort-rotated"),
"the rotated refresh token must be adopted"
);
}
/// A 401 on refresh maps to Unauthorized (drives the permanent-failure path).
#[tokio::test]
async fn refresh_401_maps_to_unauthorized() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/v1/oauth/token"))
.respond_with(
ResponseTemplate::new(401)
.set_body_json(serde_json::json!({ "error_description": "refresh revoked" })),
)
.expect(1)
.mount(&server)
.await;
let err = refresh_token(&mock_cfg(host), "ort-dead")
.await
.unwrap_err();
match err {
RefreshError::Unauthorized {
status,
description,
} => {
assert_eq!(status, 401);
assert_eq!(description, "refresh revoked");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
}
@@ -141,6 +141,52 @@ mod tests {
.expect("xai-grok carries an OAuthConfig") .expect("xai-grok carries an OAuthConfig")
} }
fn claude_oauth() -> &'static kigi_models::OAuthConfig {
kigi_models::PlatformId::ClaudeProMax
.oauth()
.expect("claude-pro-max carries an OAuthConfig")
}
/// A `claude-pro-max/<model>` turn resolves to the process-global pooled
/// claude-pro-max manager (its OWN `oauth/claude-pro-max` scope), NEVER the
/// primary Kimi manager — the same leak-safe routing as xai-grok, and a
/// DISTINCT pool entry from the xai manager.
#[tokio::test]
async fn claude_pro_max_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(), "claude-pro-max/claude-opus-4-8", Some(&kimi))
.expect("claude-pro-max model resolves to its pooled manager");
assert!(
!Arc::ptr_eq(&resolved, &kimi),
"claude-pro-max must NOT resolve to the Kimi manager"
);
assert!(
Arc::ptr_eq(&resolved, &global_manager_for(home.path(), claude_oauth())),
"claude-pro-max must resolve to its OWN process-global pooled manager"
);
// And it is a DIFFERENT manager than xai-grok's pooled one.
assert!(
!Arc::ptr_eq(&resolved, &global_manager_for(home.path(), xai_oauth())),
"claude-pro-max and xai-grok must not share a pooled manager"
);
}
/// Fail-fast (no Kimi fallback): a claude-pro-max key with a Kimi primary
/// never yields the Kimi session token — it draws from the claude pool (its
/// own token, or `None`), so the Kimi bearer can never reach api.anthropic.
#[tokio::test]
async fn session_key_for_claude_pro_max_is_never_the_kimi_primary() {
let (_kd, kimi) = primary_with_token("kimi-tok");
let home = tempfile::tempdir().unwrap();
assert_ne!(
session_key_for_model(home.path(), "claude-pro-max/claude-opus-4-8", Some(&kimi)),
Some("kimi-tok".to_string()),
"a claude-pro-max model must never receive the primary Kimi session token"
);
}
/// A non-OAuth managed key (moonshot-cn/…) and an unprefixed bare id both /// A non-OAuth managed key (moonshot-cn/…) and an unprefixed bare id both
/// route to the primary Kimi manager — the Kimi / first-party path is /// route to the primary Kimi manager — the Kimi / first-party path is
/// untouched and never consults the pool (no runtime needed). /// untouched and never consults the pool (no runtime needed).
@@ -9,12 +9,12 @@
use std::sync::Arc; use std::sync::Arc;
use kigi_models::OAuthConfig; use kigi_models::{OAuthConfig, OAuthTokenBody};
use crate::auth::error::RefreshTokenFailedReason; use crate::auth::error::RefreshTokenFailedReason;
use crate::auth::kimi_oauth::RefreshError; use crate::auth::kimi_oauth::RefreshError;
use crate::auth::manager::RefreshReason; use crate::auth::manager::RefreshReason;
use crate::auth::oauth_device::{self}; use crate::auth::{oauth_device, oauth_pkce};
use super::{AuthSnapshot, RefreshOutcome, TokenRefresher}; use super::{AuthSnapshot, RefreshOutcome, TokenRefresher};
@@ -104,7 +104,14 @@ impl TokenRefresher for GenericDeviceRefresher {
"auth: sending refresh_token grant (generic oauth)" "auth: sending refresh_token grant (generic oauth)"
); );
match oauth_device::refresh_token(self.cfg, &refresh_token).await { // Refresh over the provider's token-body encoding: xai's endpoint is
// form-encoded (device wire); Claude's is JSON (PKCE wire). Both return
// the same `Result<KimiAuth, RefreshError>`.
let wire_result = match self.cfg.token_body {
OAuthTokenBody::Form => oauth_device::refresh_token(self.cfg, &refresh_token).await,
OAuthTokenBody::Json => oauth_pkce::refresh_token(self.cfg, &refresh_token).await,
};
match wire_result {
Ok(new_auth) => { Ok(new_auth) => {
kigi_log::unified_log::info( kigi_log::unified_log::info(
"auth.refresh.token_rotated", "auth.refresh.token_rotated",
@@ -259,6 +259,21 @@ impl SessionActor {
.and_then(|(platform, _)| platform.oauth()) .and_then(|(platform, _)| platform.oauth())
.is_some() .is_some()
} }
/// Whether `model` routes to the Claude Pro/Max OAuth-Messages platform
/// (claude-pro-max) — the gate for the sampler's OAuth Messages adaptation
/// (identity headers + "You are Claude Code" system prefix). A generic-OAuth
/// platform speaking the Messages wire; every other model (incl. xai-grok,
/// which is ChatCompletions) returns `false`, keeping the API-key Anthropic
/// / MiniMax Messages requests byte-identical.
fn model_is_anthropic_oauth(&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.oauth().is_some()
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
},
)
}
/// LEAK guard for the stamped aux paths (auto-mode classifier, image /// LEAK guard for the stamped aux paths (auto-mode classifier, image
/// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`] /// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`]
/// has copied the SESSION model's `bearer_resolver` onto an aux /// has copied the SESSION model's `bearer_resolver` onto an aux
@@ -365,6 +380,9 @@ impl SessionActor {
} else { } else {
None None
}; };
// Claude Pro/Max OAuth Messages adaptation for THIS turn's model
// (captured before `cfg.model` is moved into the struct below).
let anthropic_oauth = self.model_is_anthropic_oauth(&cfg.model);
let auth_scheme = model_facts.auth_scheme; let auth_scheme = model_facts.auth_scheme;
let mut extra_headers = cfg.extra_headers; let mut extra_headers = cfg.extra_headers;
crate::agent::config::inject_url_derived_headers( crate::agent::config::inject_url_derived_headers(
@@ -405,6 +423,7 @@ impl SessionActor {
top_p: cfg.top_p, top_p: cfg.top_p,
api_backend: cfg.api_backend, api_backend: cfg.api_backend,
auth_scheme, auth_scheme,
anthropic_oauth,
chat_compat: cfg.chat_compat, chat_compat: cfg.chat_compat,
extra_headers, extra_headers,
context_window: cfg.context_window.get(), context_window: cfg.context_window.get(),
@@ -849,6 +849,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
api_backend: crate::sampling::ApiBackend::ChatCompletions, api_backend: crate::sampling::ApiBackend::ChatCompletions,
chat_compat: Default::default(), chat_compat: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 256_000, context_window: 256_000,
force_http1: false, force_http1: false,
@@ -46,6 +46,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
api_backend: Default::default(), api_backend: Default::default(),
chat_compat: Default::default(), chat_compat: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
@@ -342,6 +343,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
api_backend: Default::default(), api_backend: Default::default(),
chat_compat: Default::default(), chat_compat: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
@@ -472,6 +474,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
api_backend: Default::default(), api_backend: Default::default(),
chat_compat: Default::default(), chat_compat: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
max_retries: None, max_retries: None,
@@ -1740,6 +1743,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
api_backend: kigi_sampler::ApiBackend::Responses, api_backend: kigi_sampler::ApiBackend::Responses,
chat_compat: Default::default(), chat_compat: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 100_000, context_window: 100_000,
force_http1: false, force_http1: false,
@@ -1590,6 +1590,7 @@ mod reasoning_compaction_regression_tests {
api_backend: ApiBackend::ChatCompletions, api_backend: ApiBackend::ChatCompletions,
chat_compat: Default::default(), chat_compat: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 256_000, context_window: 256_000,
force_http1: false, force_http1: false,
@@ -46,6 +46,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
api_backend: Default::default(), api_backend: Default::default(),
chat_compat: Default::default(), chat_compat: Default::default(),
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(), extra_headers: Default::default(),
context_window: 256_000, context_window: 256_000,
force_http1: false, force_http1: false,
@@ -38,6 +38,7 @@ pub fn test_sampler_config(
top_p: None, top_p: None,
api_backend, api_backend,
auth_scheme: Default::default(), auth_scheme: Default::default(),
anthropic_oauth: false,
chat_compat: Default::default(), chat_compat: Default::default(),
extra_headers: extra_headers extra_headers: extra_headers
.iter() .iter()
+37 -30
View File
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
#[test] #[test]
fn pending_menu_items_lists_interactive_methods_plus_quit() { fn pending_menu_items_lists_interactive_methods_plus_quit() {
let items = pending_menu_items(&fresh_user_auth_methods(), None); let items = pending_menu_items(&fresh_user_auth_methods(), None);
assert_eq!(items.len(), 27, "26 login rows + Quit, got {items:?}"); assert_eq!(items.len(), 28, "27 login rows + Quit, got {items:?}");
assert!( assert!(
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
"row 0 must be the OAuth login, got {:?}", "row 0 must be the OAuth login, got {:?}",
@@ -6905,22 +6905,27 @@ pub(crate) mod tests {
"row 1 must be the xai-grok OAuth login (interactive, after kimi-code), got {:?}", "row 1 must be the xai-grok OAuth login (interactive, after kimi-code), got {:?}",
items[1] items[1]
); );
assert!(
matches!(&items[2], PendingMenuItem::Login { label } if label == "Claude Pro/Max (subscription) (OAuth)"),
"row 2 must be the claude-pro-max OAuth login (after xai-grok), got {:?}",
items[2]
);
assert_eq!( assert_eq!(
items[2], items[3],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn), target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn),
label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(), label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(),
} }
); );
assert_eq!( assert_eq!(
items[3], items[4],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi), target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi),
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(), label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
} }
); );
assert_eq!( assert_eq!(
items[4], items[5],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi), target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi),
label: "OpenAI (API key)".into(), label: "OpenAI (API key)".into(),
@@ -6928,153 +6933,153 @@ pub(crate) mod tests {
"new registry rows must appear in the picker with zero TUI changes" "new registry rows must appear in the picker with zero TUI changes"
); );
assert_eq!( assert_eq!(
items[5], items[6],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic), target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic),
label: "Anthropic (API key)".into(), label: "Anthropic (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[6], items[7],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek), target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek),
label: "DeepSeek (API key)".into(), label: "DeepSeek (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[7], items[8],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Groq), target: PlatformLogin(kigi_shell::models::PlatformId::Groq),
label: "Groq (API key)".into(), label: "Groq (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[8], items[9],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Mistral), target: PlatformLogin(kigi_shell::models::PlatformId::Mistral),
label: "Mistral (API key)".into(), label: "Mistral (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[9], items[10],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks), target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks),
label: "Fireworks AI (API key)".into(), label: "Fireworks AI (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[10], items[11],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Google), target: PlatformLogin(kigi_shell::models::PlatformId::Google),
label: "Google Gemini (API key)".into(), label: "Google Gemini (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[11], items[12],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter), target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter),
label: "OpenRouter (API key)".into(), label: "OpenRouter (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[12], items[13],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Together), target: PlatformLogin(kigi_shell::models::PlatformId::Together),
label: "Together AI (API key)".into(), label: "Together AI (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[13], items[14],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras), target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras),
label: "Cerebras (API key)".into(), label: "Cerebras (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[14], items[15],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia), target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia),
label: "NVIDIA NIM (API key)".into(), label: "NVIDIA NIM (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[15], items[16],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Vercel), target: PlatformLogin(kigi_shell::models::PlatformId::Vercel),
label: "Vercel AI Gateway (API key)".into(), label: "Vercel AI Gateway (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[16], items[17],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Xai), target: PlatformLogin(kigi_shell::models::PlatformId::Xai),
label: "xAI (Grok) (API key)".into(), label: "xAI (Grok) (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[17], items[18],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan), target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan),
label: "Qwen Token Plan (API key)".into(), label: "Qwen Token Plan (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[18], items[19],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn), target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn),
label: "Qwen Token Plan China (API key)".into(), label: "Qwen Token Plan China (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[19], items[20],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding), target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding),
label: "Kimi For Coding (API key)".into(), label: "Kimi For Coding (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[20], items[21],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Zai), target: PlatformLogin(kigi_shell::models::PlatformId::Zai),
label: "Z.AI (API key)".into(), label: "Z.AI (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[21], items[22],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn), target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn),
label: "Z.AI Coding China (API key)".into(), label: "Z.AI Coding China (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[22], items[23],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi), target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi),
label: "Xiaomi MiMo (API key)".into(), label: "Xiaomi MiMo (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[23], items[24],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn), target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn),
label: "Xiaomi Token Plan China (API key)".into(), label: "Xiaomi Token Plan China (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[24], items[25],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::Minimax), target: PlatformLogin(kigi_shell::models::PlatformId::Minimax),
label: "MiniMax (API key)".into(), label: "MiniMax (API key)".into(),
} }
); );
assert_eq!( assert_eq!(
items[25], items[26],
PendingMenuItem::ApiKey { PendingMenuItem::ApiKey {
target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn), target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn),
label: "MiniMax China (API key)".into(), label: "MiniMax China (API key)".into(),
} }
); );
assert_eq!(items[26], PendingMenuItem::Quit); assert_eq!(items[27], PendingMenuItem::Quit);
// The non-interactive methods must never appear as rows. // The non-interactive methods must never appear as rows.
let byok = kigi_shell::agent::auth_method::build_auth_methods( let byok = kigi_shell::agent::auth_method::build_auth_methods(
kigi_shell::agent::auth_method::AuthMethodsBuildInputs { kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
@@ -7085,8 +7090,8 @@ pub(crate) mod tests {
); );
assert_eq!( assert_eq!(
pending_menu_items(&byok.methods, None).len(), pending_menu_items(&byok.methods, None).len(),
27, 28,
"xai.api_key / cached_token must not add rows (26 login rows + Quit)" "xai.api_key / cached_token must not add rows (27 login rows + Quit)"
); );
} }
/// Startup lands on the picker only when there is a real choice: the /// Startup lands on the picker only when there is a real choice: the
@@ -7108,7 +7113,9 @@ pub(crate) mod tests {
app.auth_state = AuthState::Pending { error: None }; app.auth_state = AuthState::Pending { error: None };
app.welcome_prompt_focused = false; app.welcome_prompt_focused = false;
// Interactive OAuth logins come first: row 0 (kimi-code), row 1 // Interactive OAuth logins come first: row 0 (kimi-code), row 1
// (xai-grok); the first API-key row (moonshot-cn) is now row 2. // (xai-grok), row 2 (claude-pro-max); the first API-key row
// (moonshot-cn) is now row 3, so four Downs land on it.
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE));
@@ -7120,7 +7127,7 @@ pub(crate) mod tests {
kigi_shell::models::PlatformId::MoonshotCn kigi_shell::models::PlatformId::MoonshotCn
))) )))
), ),
"Enter on row 1 must open moonshot-cn key entry, got {outcome:?}" "Enter on the moonshot-cn row must open its key entry, got {outcome:?}"
); );
// 'l' is muscle-memory for the first (OAuth) row regardless of the // 'l' is muscle-memory for the first (OAuth) row regardless of the
// arrow selection. // arrow selection.
@@ -2106,6 +2106,14 @@ mod tests {
// lists ~20 platforms); this asserts content coverage, not fit. // lists ~20 platforms); this asserts content coverage, not fit.
let text = render_done_text_h(&params, 72); let text = render_done_text_h(&params, 72);
assert!(text.contains("Kimi Code (OAuth)"), "{text}"); assert!(text.contains("Kimi Code (OAuth)"), "{text}");
assert!(
text.contains("xAI Grok (subscription) (OAuth)"),
"the xai-grok interactive OAuth login row must render: {text}"
);
assert!(
text.contains("Claude Pro/Max (subscription) (OAuth)"),
"the claude-pro-max interactive OAuth login row must render: {text}"
);
assert!( assert!(
text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"), text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
"{text}" "{text}"