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
@@ -259,6 +259,21 @@ impl SessionActor {
.and_then(|(platform, _)| platform.oauth())
.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
/// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`]
/// has copied the SESSION model's `bearer_resolver` onto an aux
@@ -365,6 +380,9 @@ impl SessionActor {
} else {
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 mut extra_headers = cfg.extra_headers;
crate::agent::config::inject_url_derived_headers(
@@ -405,6 +423,7 @@ impl SessionActor {
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(),
@@ -849,6 +849,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
api_backend: crate::sampling::ApiBackend::ChatCompletions,
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(),
context_window: 256_000,
force_http1: false,
@@ -46,6 +46,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(),
context_window: 100_000,
force_http1: false,
@@ -342,6 +343,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
context_window: 100_000,
force_http1: false,
max_retries: None,
@@ -472,6 +474,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
context_window: 100_000,
force_http1: false,
max_retries: None,
@@ -1740,6 +1743,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
api_backend: kigi_sampler::ApiBackend::Responses,
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(),
context_window: 100_000,
force_http1: false,
@@ -1590,6 +1590,7 @@ mod reasoning_compaction_regression_tests {
api_backend: ApiBackend::ChatCompletions,
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(),
context_window: 256_000,
force_http1: false,