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:
@@ -89,6 +89,7 @@ mod tests {
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: Default::default(),
|
||||
anthropic_oauth: false,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
|
||||
@@ -41,6 +41,35 @@ pub use kigi_sampling_types::ApiBackend;
|
||||
const AGENT_PRODUCT: &str = "kigi";
|
||||
const ANTHROPIC_DEFAULT_MAX_TOKENS: u32 = 128_000;
|
||||
|
||||
/// Prepend the required Claude-Code system block to a Messages request's
|
||||
/// `system` field (Claude Pro/Max OAuth path). Anthropic inspects the FIRST
|
||||
/// system block, so the prefix is inserted as a distinct leading `text` block
|
||||
/// while preserving any caller-supplied prompt (string or block form).
|
||||
/// Idempotent: a leading block already equal to the prefix is not re-added.
|
||||
fn prepend_claude_code_system_prefix(system: &mut Option<messages::SystemParam>) {
|
||||
use messages::{SystemParam, TextBlock};
|
||||
let text_block = |text: String| TextBlock {
|
||||
r#type: "text".to_string(),
|
||||
text,
|
||||
cache_control: None,
|
||||
};
|
||||
let mut blocks = match system.take() {
|
||||
None => Vec::new(),
|
||||
Some(SystemParam::Text(text)) => vec![text_block(text)],
|
||||
Some(SystemParam::Blocks(blocks)) => blocks,
|
||||
};
|
||||
let already_present = blocks
|
||||
.first()
|
||||
.is_some_and(|b| b.text == kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX);
|
||||
if !already_present {
|
||||
blocks.insert(
|
||||
0,
|
||||
text_block(kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX.to_string()),
|
||||
);
|
||||
}
|
||||
*system = Some(SystemParam::Blocks(blocks));
|
||||
}
|
||||
|
||||
/// Parse the `Retry-After` response header as delta-seconds.
|
||||
/// Our inference backends only emit integer seconds (never HTTP-date),
|
||||
/// so we only handle that form. HTTP-dates silently return `None` and
|
||||
@@ -273,6 +302,8 @@ struct ClientDefaults {
|
||||
chat_compat: kigi_sampling_types::ChatCompat,
|
||||
stream_tool_calls: bool,
|
||||
doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>,
|
||||
/// Claude Pro/Max OAuth Messages adaptation (see [`SamplerConfig`]).
|
||||
anthropic_oauth: bool,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -398,6 +429,31 @@ impl SamplingClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Pro/Max OAuth identity headers (claude-pro-max only). The
|
||||
// OAuth `sk-ant-oat…` bearer is Claude-Code-scoped, so Anthropic
|
||||
// rejects the Messages request without the oauth beta + claude-cli
|
||||
// identity. Gated on `anthropic_oauth` so API-key anthropic/minimax
|
||||
// requests carry none of this and stay byte-identical. `Accept` is set
|
||||
// per-request (text/event-stream for streams), so it is NOT added here.
|
||||
if config.anthropic_oauth {
|
||||
headers.insert(
|
||||
HeaderName::from_static("anthropic-version"),
|
||||
HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("anthropic-beta"),
|
||||
HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_OAUTH_BETA),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-app"),
|
||||
HeaderValue::from_static("cli"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("anthropic-dangerous-direct-browser-access"),
|
||||
HeaderValue::from_static("true"),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply all extra headers verbatim. This is the single
|
||||
// injection point for proxy-auth headers and any other URL- or
|
||||
// environment-specific headers the session decides to set.
|
||||
@@ -415,12 +471,18 @@ impl SamplingClient {
|
||||
// (PRD F3: auth is a plain bearer; kimi-cli sends only User-Agent
|
||||
// plus the OAuth device headers, src/kimi_cli/llm.py:317-323).
|
||||
{
|
||||
let ua_string = match config.origin_client.as_ref() {
|
||||
Some(origin) => user_agent_string_for(origin),
|
||||
None => user_agent_string_for(&OriginClientInfo {
|
||||
product: AGENT_PRODUCT.to_string(),
|
||||
version: Some(agent_version()),
|
||||
}),
|
||||
// Claude Pro/Max OAuth path presents the claude-cli identity;
|
||||
// every other path keeps the kigi User-Agent.
|
||||
let ua_string = if config.anthropic_oauth {
|
||||
kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string()
|
||||
} else {
|
||||
match config.origin_client.as_ref() {
|
||||
Some(origin) => user_agent_string_for(origin),
|
||||
None => user_agent_string_for(&OriginClientInfo {
|
||||
product: AGENT_PRODUCT.to_string(),
|
||||
version: Some(agent_version()),
|
||||
}),
|
||||
}
|
||||
};
|
||||
if let Ok(v) = HeaderValue::from_str(&ua_string) {
|
||||
headers.insert(USER_AGENT, v);
|
||||
@@ -460,6 +522,7 @@ impl SamplingClient {
|
||||
chat_compat: config.chat_compat,
|
||||
stream_tool_calls: config.stream_tool_calls,
|
||||
doom_loop_recovery: config.doom_loop_recovery,
|
||||
anthropic_oauth: config.anthropic_oauth,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
@@ -1344,6 +1407,15 @@ impl SamplingClient {
|
||||
|
||||
/// Apply default configuration to a Messages API request.
|
||||
fn apply_message_defaults(&self, request: &mut MessagesRequestWrapper) -> Result<()> {
|
||||
// Claude Pro/Max OAuth adaptation (claude-pro-max only): the OAuth
|
||||
// token is Claude-Code-scoped, so the request MUST lead with the exact
|
||||
// "You are Claude Code…" system block or Anthropic rejects it. Prepend
|
||||
// it as a distinct first system block, preserving any caller prompt.
|
||||
// Gated on `anthropic_oauth` so API-key anthropic/minimax are untouched.
|
||||
if self.defaults.anthropic_oauth {
|
||||
prepend_claude_code_system_prefix(&mut request.inner.system);
|
||||
}
|
||||
|
||||
// Apply model default if not specified
|
||||
if request.inner.model.is_empty() {
|
||||
request.inner.model = self.defaults.model.clone();
|
||||
@@ -1888,6 +1960,7 @@ mod tests {
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
anthropic_oauth: false,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
@@ -1942,6 +2015,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Claude Pro/Max OAuth Messages client (`anthropic_oauth = true`, Bearer,
|
||||
/// Messages) carries the full OAuth identity: Bearer auth, anthropic-version,
|
||||
/// the oauth `anthropic-beta`, `x-app: cli`, the claude-cli User-Agent, and
|
||||
/// the direct-browser-access header.
|
||||
#[test]
|
||||
fn anthropic_oauth_messages_client_sends_oauth_identity_headers() {
|
||||
let mut config = minimal_config();
|
||||
config.api_key = Some("sk-ant-oat-secret".to_string());
|
||||
config.auth_scheme = AuthScheme::Bearer;
|
||||
config.api_backend = ApiBackend::Messages;
|
||||
config.anthropic_oauth = true;
|
||||
let client = SamplingClient::new(config).expect("client builds");
|
||||
let h = &client.default_headers;
|
||||
assert_eq!(
|
||||
h.get(AUTHORIZATION).and_then(|v| v.to_str().ok()),
|
||||
Some("Bearer sk-ant-oat-secret"),
|
||||
"OAuth path rides Authorization: Bearer, never x-api-key"
|
||||
);
|
||||
assert!(
|
||||
h.get("x-api-key").is_none(),
|
||||
"OAuth path must not send x-api-key"
|
||||
);
|
||||
assert_eq!(
|
||||
h.get("anthropic-version").and_then(|v| v.to_str().ok()),
|
||||
Some(kigi_sampling_types::ANTHROPIC_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
h.get("anthropic-beta").and_then(|v| v.to_str().ok()),
|
||||
Some(kigi_sampling_types::ANTHROPIC_OAUTH_BETA)
|
||||
);
|
||||
assert_eq!(h.get("x-app").and_then(|v| v.to_str().ok()), Some("cli"));
|
||||
assert_eq!(
|
||||
h.get(USER_AGENT).and_then(|v| v.to_str().ok()),
|
||||
Some(kigi_sampling_types::CLAUDE_CODE_USER_AGENT)
|
||||
);
|
||||
assert_eq!(
|
||||
h.get("anthropic-dangerous-direct-browser-access")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION: an API-key Anthropic Messages client (XApiKey, NOT oauth)
|
||||
/// carries NONE of the OAuth identity — no anthropic-beta, no x-app, and
|
||||
/// the kigi User-Agent — so the API-key path stays byte-identical.
|
||||
#[test]
|
||||
fn api_key_anthropic_messages_client_has_no_oauth_identity() {
|
||||
let mut config = minimal_config();
|
||||
config.auth_scheme = AuthScheme::XApiKey;
|
||||
config.api_backend = ApiBackend::Messages;
|
||||
// anthropic_oauth stays false.
|
||||
let client = SamplingClient::new(config).expect("client builds");
|
||||
let h = &client.default_headers;
|
||||
assert!(
|
||||
h.get("anthropic-beta").is_none(),
|
||||
"API-key anthropic must NOT send the oauth beta"
|
||||
);
|
||||
assert!(
|
||||
h.get("x-app").is_none(),
|
||||
"API-key anthropic must NOT send x-app"
|
||||
);
|
||||
assert!(h.get("anthropic-dangerous-direct-browser-access").is_none());
|
||||
assert!(
|
||||
h.get(USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|ua| ua.starts_with("kigi/")),
|
||||
"API-key anthropic keeps the kigi User-Agent"
|
||||
);
|
||||
}
|
||||
|
||||
/// The system-prompt prefix is prepended as a distinct leading `text`
|
||||
/// block for each `system` shape (absent / string / blocks), preserving the
|
||||
/// caller's prompt, and is idempotent (not stamped twice).
|
||||
#[test]
|
||||
fn claude_code_system_prefix_prepends_and_is_idempotent() {
|
||||
use messages::{SystemParam, TextBlock};
|
||||
let prefix = kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX;
|
||||
|
||||
// Absent system → a single prefix block.
|
||||
let mut none = None;
|
||||
prepend_claude_code_system_prefix(&mut none);
|
||||
match none {
|
||||
Some(SystemParam::Blocks(b)) => {
|
||||
assert_eq!(b.len(), 1);
|
||||
assert_eq!(b[0].text, prefix);
|
||||
}
|
||||
other => panic!("expected one prefix block, got {other:?}"),
|
||||
}
|
||||
|
||||
// String system → [prefix, original].
|
||||
let mut text = Some(SystemParam::Text("do the thing".into()));
|
||||
prepend_claude_code_system_prefix(&mut text);
|
||||
match text {
|
||||
Some(SystemParam::Blocks(b)) => {
|
||||
assert_eq!(b.len(), 2);
|
||||
assert_eq!(b[0].text, prefix);
|
||||
assert_eq!(b[1].text, "do the thing");
|
||||
}
|
||||
other => panic!("expected two blocks, got {other:?}"),
|
||||
}
|
||||
|
||||
// Idempotent: a leading prefix block is not re-added.
|
||||
let mut already = Some(SystemParam::Blocks(vec![
|
||||
TextBlock {
|
||||
r#type: "text".into(),
|
||||
text: prefix.to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
TextBlock {
|
||||
r#type: "text".into(),
|
||||
text: "tail".into(),
|
||||
cache_control: None,
|
||||
},
|
||||
]));
|
||||
prepend_claude_code_system_prefix(&mut already);
|
||||
match already {
|
||||
Some(SystemParam::Blocks(b)) => {
|
||||
assert_eq!(b.len(), 2, "prefix must not be duplicated");
|
||||
assert_eq!(b[0].text, prefix);
|
||||
}
|
||||
other => panic!("expected unchanged blocks, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the serialized shape of StreamingChatRequest matches the
|
||||
/// expected wire format: all ChatCompletionRequest fields flattened at
|
||||
/// top level, plus `stream: true` and `stream_options.include_usage: true`.
|
||||
|
||||
@@ -55,6 +55,13 @@ pub struct SamplerConfig {
|
||||
pub api_backend: ApiBackend,
|
||||
#[serde(default)]
|
||||
pub auth_scheme: AuthScheme,
|
||||
/// Claude Pro/Max OAuth adaptation (claude-pro-max only). When true the
|
||||
/// Messages request carries the OAuth identity headers (`anthropic-beta`
|
||||
/// oauth, `claude-cli` User-Agent, `x-app: cli`) and its system prompt is
|
||||
/// prefixed with the required "You are Claude Code…" line. Gated so the
|
||||
/// API-key `anthropic` + `minimax` Messages requests stay byte-identical.
|
||||
#[serde(default)]
|
||||
pub anthropic_oauth: bool,
|
||||
/// Extra request headers applied verbatim. The sampler never inspects
|
||||
/// the URL to derive headers; callers (the session) inject proxy auth
|
||||
/// and other access headers here before constructing the config.
|
||||
@@ -144,6 +151,7 @@ impl Default for SamplerConfig {
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::default(),
|
||||
auth_scheme: AuthScheme::default(),
|
||||
anthropic_oauth: false,
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 0,
|
||||
force_http1: false,
|
||||
|
||||
Reference in New Issue
Block a user