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