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:
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
||||
assert_eq!(items.len(), 27, "26 login rows + Quit, got {items:?}");
|
||||
assert_eq!(items.len(), 28, "27 login rows + Quit, got {items:?}");
|
||||
assert!(
|
||||
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
|
||||
"row 0 must be the OAuth login, got {:?}",
|
||||
@@ -6905,22 +6905,27 @@ pub(crate) mod tests {
|
||||
"row 1 must be the xai-grok OAuth login (interactive, after kimi-code), got {:?}",
|
||||
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!(
|
||||
items[2],
|
||||
items[3],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn),
|
||||
label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[3],
|
||||
items[4],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi),
|
||||
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[4],
|
||||
items[5],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi),
|
||||
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"
|
||||
);
|
||||
assert_eq!(
|
||||
items[5],
|
||||
items[6],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic),
|
||||
label: "Anthropic (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[6],
|
||||
items[7],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek),
|
||||
label: "DeepSeek (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[7],
|
||||
items[8],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Groq),
|
||||
label: "Groq (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[8],
|
||||
items[9],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Mistral),
|
||||
label: "Mistral (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[9],
|
||||
items[10],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks),
|
||||
label: "Fireworks AI (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[10],
|
||||
items[11],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Google),
|
||||
label: "Google Gemini (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[11],
|
||||
items[12],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter),
|
||||
label: "OpenRouter (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[12],
|
||||
items[13],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Together),
|
||||
label: "Together AI (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[13],
|
||||
items[14],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras),
|
||||
label: "Cerebras (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[14],
|
||||
items[15],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia),
|
||||
label: "NVIDIA NIM (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[15],
|
||||
items[16],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Vercel),
|
||||
label: "Vercel AI Gateway (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[16],
|
||||
items[17],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Xai),
|
||||
label: "xAI (Grok) (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[17],
|
||||
items[18],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan),
|
||||
label: "Qwen Token Plan (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[18],
|
||||
items[19],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn),
|
||||
label: "Qwen Token Plan China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[19],
|
||||
items[20],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding),
|
||||
label: "Kimi For Coding (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[20],
|
||||
items[21],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Zai),
|
||||
label: "Z.AI (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[21],
|
||||
items[22],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn),
|
||||
label: "Z.AI Coding China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[22],
|
||||
items[23],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi),
|
||||
label: "Xiaomi MiMo (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[23],
|
||||
items[24],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn),
|
||||
label: "Xiaomi Token Plan China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[24],
|
||||
items[25],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::Minimax),
|
||||
label: "MiniMax (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[25],
|
||||
items[26],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn),
|
||||
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.
|
||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||
@@ -7085,8 +7090,8 @@ pub(crate) mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
pending_menu_items(&byok.methods, None).len(),
|
||||
27,
|
||||
"xai.api_key / cached_token must not add rows (26 login rows + Quit)"
|
||||
28,
|
||||
"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
|
||||
@@ -7108,7 +7113,9 @@ pub(crate) mod tests {
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
app.welcome_prompt_focused = false;
|
||||
// Interactive OAuth logins come first: row 0 (kimi-code), row 1
|
||||
// (xai-grok); 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));
|
||||
@@ -7120,7 +7127,7 @@ pub(crate) mod tests {
|
||||
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
|
||||
// arrow selection.
|
||||
|
||||
@@ -2106,6 +2106,14 @@ mod tests {
|
||||
// lists ~20 platforms); this asserts content coverage, not fit.
|
||||
let text = render_done_text_h(¶ms, 72);
|
||||
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!(
|
||||
text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
|
||||
"{text}"
|
||||
|
||||
Reference in New Issue
Block a user