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:
ZacharyZhang-NY committed 2026-07-22 02:53:25 -04:00
1 parent dbce6bf305
commit 5a9183b08b
25 files changed
+1527 -73

No files matched your search

@@ -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.