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
@@ -595,6 +595,11 @@ mod tests {
);
assert_eq!(
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,
"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);
}
/// 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
/// method — `platform_for_method_id`'s `uses_oauth` filter is what keeps
/// the generic `authenticate` arm from hijacking the device login.
@@ -721,6 +749,7 @@ mod tests {
XAI_API_KEY_METHOD_ID,
KIMI_CODE_METHOD_ID,
"xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",
@@ -770,6 +799,7 @@ mod tests {
CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID,
"xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",
@@ -812,6 +842,7 @@ mod tests {
CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID,
"xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",
@@ -857,6 +888,7 @@ mod tests {
vec![
KIMI_CODE_METHOD_ID,
"xai-grok",
"claude-pro-max",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",
@@ -4079,6 +4079,18 @@ pub fn sampling_config_for_model(
}
})
.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 {
api_key: credentials.api_key,
model: model_name,
@@ -4088,6 +4100,7 @@ pub fn sampling_config_for_model(
top_p,
api_backend,
auth_scheme: credentials.auth_scheme,
anthropic_oauth,
chat_compat,
extra_headers,
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");
let request = match platform.key_header() {
kigi_models::PlatformKeyHeader::Bearer => client
.get(&url)
.header("Authorization", format!("Bearer {}", bearer)),
kigi_models::PlatformKeyHeader::Bearer => {
let mut req = client
.get(&url)
.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
.get(&url)
.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
/// (high/max) produce ChatCompletions entries whose sampler config
/// 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)
.map(|r| r.auth_scheme)
.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 {
api_key: creds.api_key,
base_url: cfg.base_url,
@@ -889,6 +897,7 @@ async fn read_parent_sampling_config(
top_p: cfg.top_p,
api_backend: cfg.api_backend,
auth_scheme,
anthropic_oauth,
chat_compat: cfg.chat_compat,
extra_headers,
context_window: cfg.context_window.get(),