From 313c6f6801cc02cf7cceb95cbf262554eef83bf6 Mon Sep 17 00:00:00 2001 From: Tao Hansen Date: Tue, 24 Feb 2026 09:05:25 +0100 Subject: [PATCH] fix: use Bearer auth when key comes from ANTHROPIC_AUTH_TOKEN When ANTHROPIC_AUTH_TOKEN is the credential source (instead of ANTHROPIC_API_KEY), proxy endpoints expect Authorization: Bearer rather than x-api-key. This matches Claude Code's behavior. Adds a ProxyBearer auth path that sends Bearer without Claude Code identity headers (user-agent, x-app, oauth beta). The auth source is tracked via use_bearer_auth on ProviderConfig, set automatically when the key originates from ANTHROPIC_AUTH_TOKEN. Fixes the 403 errors reported in #135 when using corporate proxies. --- src/api/providers.rs | 17 ++++++++ src/config.rs | 59 ++++++++++++++++++++++++++ src/llm/anthropic/auth.rs | 82 +++++++++++++++++++++++++++++++++---- src/llm/anthropic/params.rs | 5 ++- src/llm/manager.rs | 2 + src/llm/model.rs | 1 + 6 files changed, 157 insertions(+), 9 deletions(-) diff --git a/src/api/providers.rs b/src/api/providers.rs index 954409992..f2df04bc1 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -174,102 +174,119 @@ fn build_test_llm_config(provider: &str, credential: &str) -> crate::config::Llm base_url: "https://api.anthropic.com".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "openai" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.openai.com".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "openrouter" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://openrouter.ai/api".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "zhipu" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.z.ai/api/paas/v4".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "groq" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.groq.com/openai".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "together" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.together.xyz".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "fireworks" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.fireworks.ai/inference".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "deepseek" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.deepseek.com".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "xai" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.x.ai".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "mistral" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.mistral.ai".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "gemini" => Some(ProviderConfig { api_type: ApiType::Gemini, base_url: crate::config::GEMINI_PROVIDER_BASE_URL.to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "opencode-zen" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://opencode.ai/zen".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "nvidia" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://integrate.api.nvidia.com".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "minimax" => Some(ProviderConfig { api_type: ApiType::Anthropic, base_url: "https://api.minimax.io/anthropic".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "minimax-cn" => Some(ProviderConfig { api_type: ApiType::Anthropic, base_url: "https://api.minimaxi.com/anthropic".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "moonshot" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.moonshot.ai".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), "zai-coding-plan" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.z.ai/api/coding/paas/v4".to_string(), api_key: credential.to_string(), name: None, + use_bearer_auth: false, }), _ => None, }; diff --git a/src/config.rs b/src/config.rs index 3d3f79349..0b315081b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -136,6 +136,10 @@ pub struct ProviderConfig { pub base_url: String, pub api_key: String, pub name: Option, + /// When true, use `Authorization: Bearer` instead of `x-api-key` for + /// Anthropic requests. Set automatically when the key originates from + /// `ANTHROPIC_AUTH_TOKEN` (proxy-compatible auth). + pub use_bearer_auth: bool, } impl std::fmt::Debug for ProviderConfig { @@ -145,6 +149,7 @@ impl std::fmt::Debug for ProviderConfig { .field("base_url", &self.base_url) .field("api_key", &"[REDACTED]") .field("name", &self.name) + .field("use_bearer_auth", &self.use_bearer_auth) .finish() } } @@ -2176,6 +2181,8 @@ impl Config { /// Load from environment variables only (no config file). pub fn load_from_env(instance_dir: &Path) -> Result { + let anthropic_from_auth_token = std::env::var("ANTHROPIC_API_KEY").is_err() + && std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok(); let mut llm = LlmConfig { anthropic_key: std::env::var("ANTHROPIC_API_KEY") .ok() @@ -2212,6 +2219,7 @@ impl Config { base_url, api_key: anthropic_key, name: None, + use_bearer_auth: anthropic_from_auth_token, }); } @@ -2223,6 +2231,7 @@ impl Config { base_url: OPENAI_PROVIDER_BASE_URL.to_string(), api_key: openai_key, name: None, + use_bearer_auth: false, }); } @@ -2234,6 +2243,7 @@ impl Config { base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), api_key: openrouter_key, name: None, + use_bearer_auth: false, }); } @@ -2245,6 +2255,7 @@ impl Config { base_url: ZHIPU_PROVIDER_BASE_URL.to_string(), api_key: zhipu_key, name: None, + use_bearer_auth: false, }); } @@ -2256,6 +2267,7 @@ impl Config { base_url: ZAI_CODING_PLAN_BASE_URL.to_string(), api_key: zai_coding_plan_key, name: None, + use_bearer_auth: false, }); } @@ -2267,6 +2279,7 @@ impl Config { base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), api_key: opencode_zen_key, name: None, + use_bearer_auth: false, }); } @@ -2278,6 +2291,7 @@ impl Config { base_url: MINIMAX_PROVIDER_BASE_URL.to_string(), api_key: minimax_key, name: None, + use_bearer_auth: false, }); } @@ -2289,6 +2303,7 @@ impl Config { base_url: MINIMAX_CN_PROVIDER_BASE_URL.to_string(), api_key: minimax_cn_key, name: None, + use_bearer_auth: false, }); } @@ -2300,6 +2315,7 @@ impl Config { base_url: MOONSHOT_PROVIDER_BASE_URL.to_string(), api_key: moonshot_key, name: None, + use_bearer_auth: false, }); } @@ -2311,6 +2327,7 @@ impl Config { base_url: NVIDIA_PROVIDER_BASE_URL.to_string(), api_key: nvidia_key, name: None, + use_bearer_auth: false, }); } @@ -2322,6 +2339,7 @@ impl Config { base_url: FIREWORKS_PROVIDER_BASE_URL.to_string(), api_key: fireworks_key, name: None, + use_bearer_auth: false, }); } @@ -2333,6 +2351,7 @@ impl Config { base_url: DEEPSEEK_PROVIDER_BASE_URL.to_string(), api_key: deepseek_key, name: None, + use_bearer_auth: false, }); } @@ -2344,6 +2363,7 @@ impl Config { base_url: GEMINI_PROVIDER_BASE_URL.to_string(), api_key: gemini_key, name: None, + use_bearer_auth: false, }); } @@ -2355,6 +2375,7 @@ impl Config { base_url: GROQ_PROVIDER_BASE_URL.to_string(), api_key: groq_key, name: None, + use_bearer_auth: false, }); } @@ -2366,6 +2387,7 @@ impl Config { base_url: TOGETHER_PROVIDER_BASE_URL.to_string(), api_key: together_key, name: None, + use_bearer_auth: false, }); } @@ -2377,6 +2399,7 @@ impl Config { base_url: XAI_PROVIDER_BASE_URL.to_string(), api_key: xai_key, name: None, + use_bearer_auth: false, }); } @@ -2388,6 +2411,7 @@ impl Config { base_url: MISTRAL_PROVIDER_BASE_URL.to_string(), api_key: mistral_key, name: None, + use_bearer_auth: false, }); } @@ -2402,6 +2426,7 @@ impl Config { .unwrap_or_else(|| "http://localhost:11434".to_string()), api_key: llm.ollama_key.clone().unwrap_or_default(), name: None, + use_bearer_auth: false, }); } @@ -2513,6 +2538,13 @@ impl Config { } } + let toml_llm_anthropic_key_was_none = toml + .llm + .anthropic_key + .as_deref() + .and_then(resolve_env_value) + .is_none(); + let mut llm = LlmConfig { anthropic_key: toml .llm @@ -2644,12 +2676,21 @@ impl Config { base_url: config.base_url, api_key, name: config.name, + use_bearer_auth: false, }, )) }) .collect::>()?, }; + // Detect if the Anthropic key came from ANTHROPIC_AUTH_TOKEN (proxy auth). + // In from_toml, the key may come from toml config, ANTHROPIC_API_KEY, or + // ANTHROPIC_AUTH_TOKEN (in that priority order). We only set use_bearer_auth + // if AUTH_TOKEN was the actual source. + let anthropic_from_auth_token = toml_llm_anthropic_key_was_none + && std::env::var("ANTHROPIC_API_KEY").is_err() + && std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok(); + if let Some(anthropic_key) = llm.anthropic_key.clone() { let base_url = std::env::var("ANTHROPIC_BASE_URL") .unwrap_or_else(|_| ANTHROPIC_PROVIDER_BASE_URL.to_string()); @@ -2660,6 +2701,7 @@ impl Config { base_url, api_key: anthropic_key, name: None, + use_bearer_auth: anthropic_from_auth_token, }); } @@ -2671,6 +2713,7 @@ impl Config { base_url: OPENAI_PROVIDER_BASE_URL.to_string(), api_key: openai_key, name: None, + use_bearer_auth: false, }); } @@ -2682,6 +2725,7 @@ impl Config { base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), api_key: openrouter_key, name: None, + use_bearer_auth: false, }); } @@ -2693,6 +2737,7 @@ impl Config { base_url: ZHIPU_PROVIDER_BASE_URL.to_string(), api_key: zhipu_key, name: None, + use_bearer_auth: false, }); } @@ -2704,6 +2749,7 @@ impl Config { base_url: ZAI_CODING_PLAN_BASE_URL.to_string(), api_key: zai_coding_plan_key, name: None, + use_bearer_auth: false, }); } @@ -2715,6 +2761,7 @@ impl Config { base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), api_key: opencode_zen_key, name: None, + use_bearer_auth: false, }); } @@ -2726,6 +2773,7 @@ impl Config { base_url: MINIMAX_PROVIDER_BASE_URL.to_string(), api_key: minimax_key, name: None, + use_bearer_auth: false, }); } @@ -2737,6 +2785,7 @@ impl Config { base_url: MINIMAX_CN_PROVIDER_BASE_URL.to_string(), api_key: minimax_cn_key, name: None, + use_bearer_auth: false, }); } @@ -2748,6 +2797,7 @@ impl Config { base_url: MOONSHOT_PROVIDER_BASE_URL.to_string(), api_key: moonshot_key, name: None, + use_bearer_auth: false, }); } @@ -2759,6 +2809,7 @@ impl Config { base_url: NVIDIA_PROVIDER_BASE_URL.to_string(), api_key: nvidia_key, name: None, + use_bearer_auth: false, }); } @@ -2770,6 +2821,7 @@ impl Config { base_url: FIREWORKS_PROVIDER_BASE_URL.to_string(), api_key: fireworks_key, name: None, + use_bearer_auth: false, }); } @@ -2781,6 +2833,7 @@ impl Config { base_url: DEEPSEEK_PROVIDER_BASE_URL.to_string(), api_key: deepseek_key, name: None, + use_bearer_auth: false, }); } @@ -2792,6 +2845,7 @@ impl Config { base_url: GEMINI_PROVIDER_BASE_URL.to_string(), api_key: gemini_key, name: None, + use_bearer_auth: false, }); } @@ -2803,6 +2857,7 @@ impl Config { base_url: GROQ_PROVIDER_BASE_URL.to_string(), api_key: groq_key, name: None, + use_bearer_auth: false, }); } @@ -2814,6 +2869,7 @@ impl Config { base_url: TOGETHER_PROVIDER_BASE_URL.to_string(), api_key: together_key, name: None, + use_bearer_auth: false, }); } @@ -2825,6 +2881,7 @@ impl Config { base_url: XAI_PROVIDER_BASE_URL.to_string(), api_key: xai_key, name: None, + use_bearer_auth: false, }); } @@ -2836,6 +2893,7 @@ impl Config { base_url: MISTRAL_PROVIDER_BASE_URL.to_string(), api_key: mistral_key, name: None, + use_bearer_auth: false, }); } @@ -2850,6 +2908,7 @@ impl Config { .unwrap_or_else(|| "http://localhost:11434".to_string()), api_key: llm.ollama_key.clone().unwrap_or_default(), name: None, + use_bearer_auth: false, }); } diff --git a/src/llm/anthropic/auth.rs b/src/llm/anthropic/auth.rs index 87ae7c5cb..6e7317a67 100644 --- a/src/llm/anthropic/auth.rs +++ b/src/llm/anthropic/auth.rs @@ -88,11 +88,19 @@ pub enum AnthropicAuthPath { ApiKey, /// OAuth token (sk-ant-oat*) — uses Bearer auth with Claude Code identity. OAuthToken, + /// Proxy bearer token — uses `Authorization: Bearer` without Claude Code + /// identity headers. Used when key originates from `ANTHROPIC_AUTH_TOKEN`. + ProxyBearer, } /// Detect the auth path from a token's prefix. -pub fn detect_auth_path(token: &str) -> AnthropicAuthPath { - if token.starts_with("sk-ant-oat") { +/// +/// If `force_bearer` is true (key came from `ANTHROPIC_AUTH_TOKEN`), +/// returns `ProxyBearer` regardless of prefix. +pub fn detect_auth_path(token: &str, force_bearer: bool) -> AnthropicAuthPath { + if force_bearer { + AnthropicAuthPath::ProxyBearer + } else if token.starts_with("sk-ant-oat") { AnthropicAuthPath::OAuthToken } else { AnthropicAuthPath::ApiKey @@ -107,8 +115,9 @@ pub fn apply_auth_headers( builder: RequestBuilder, token: &str, interleaved_thinking: bool, + force_bearer: bool, ) -> (RequestBuilder, AnthropicAuthPath) { - let auth_path = detect_auth_path(token); + let auth_path = detect_auth_path(token, force_bearer); let mut beta_parts: Vec<&str> = Vec::new(); let builder = match auth_path { @@ -125,6 +134,10 @@ pub fn apply_auth_headers( .header("user-agent", CLAUDE_CODE_USER_AGENT) .header("x-app", "cli") } + AnthropicAuthPath::ProxyBearer => { + beta_parts.push(BETA_FINE_GRAINED_STREAMING); + builder.header("Authorization", format!("Bearer {token}")) + } }; if interleaved_thinking { @@ -142,16 +155,24 @@ mod tests { use super::*; fn build_request(token: &str, thinking: bool) -> (reqwest::Request, AnthropicAuthPath) { + build_request_with_bearer(token, thinking, false) + } + + fn build_request_with_bearer( + token: &str, + thinking: bool, + force_bearer: bool, + ) -> (reqwest::Request, AnthropicAuthPath) { let client = reqwest::Client::new(); let builder = client.post("https://api.anthropic.com/v1/messages"); - let (builder, auth_path) = apply_auth_headers(builder, token, thinking); + let (builder, auth_path) = apply_auth_headers(builder, token, thinking, force_bearer); (builder.build().unwrap(), auth_path) } #[test] fn oauth_token_detected_correctly() { assert_eq!( - detect_auth_path("sk-ant-oat01-abc123"), + detect_auth_path("sk-ant-oat01-abc123", false), AnthropicAuthPath::OAuthToken ); } @@ -159,7 +180,7 @@ mod tests { #[test] fn api_key_detected_correctly() { assert_eq!( - detect_auth_path("sk-ant-api03-xyz789"), + detect_auth_path("sk-ant-api03-xyz789", false), AnthropicAuthPath::ApiKey ); } @@ -167,11 +188,23 @@ mod tests { #[test] fn unknown_prefix_defaults_to_api_key() { assert_eq!( - detect_auth_path("some-random-key"), + detect_auth_path("some-random-key", false), AnthropicAuthPath::ApiKey ); } + #[test] + fn force_bearer_overrides_prefix() { + assert_eq!( + detect_auth_path("sk-ant-api03-xyz789", true), + AnthropicAuthPath::ProxyBearer + ); + assert_eq!( + detect_auth_path("some-proxy-token", true), + AnthropicAuthPath::ProxyBearer + ); + } + #[test] fn oauth_token_uses_bearer_header() { let (request, auth_path) = build_request("sk-ant-oat01-abc123", false); @@ -247,4 +280,39 @@ mod tests { .unwrap(); assert!(!beta.contains(BETA_INTERLEAVED_THINKING)); } + + #[test] + fn proxy_bearer_uses_bearer_header() { + let (request, auth_path) = + build_request_with_bearer("my-proxy-token", false, true); + assert_eq!(auth_path, AnthropicAuthPath::ProxyBearer); + assert_eq!( + request.headers().get("Authorization").unwrap(), + "Bearer my-proxy-token" + ); + assert!(request.headers().get("x-api-key").is_none()); + } + + #[test] + fn proxy_bearer_has_no_identity_headers() { + let (request, _) = build_request_with_bearer("my-proxy-token", false, true); + assert!(request.headers().get("x-app").is_none()); + // Should not have Claude Code user-agent + let ua = request.headers().get("user-agent").map(|v| v.to_str().unwrap().to_string()); + assert!(ua.is_none() || !ua.unwrap().contains("claude-code")); + } + + #[test] + fn proxy_bearer_has_streaming_beta_but_not_oauth() { + let (request, _) = build_request_with_bearer("my-proxy-token", false, true); + let beta = request + .headers() + .get("anthropic-beta") + .unwrap() + .to_str() + .unwrap(); + assert!(beta.contains(BETA_FINE_GRAINED_STREAMING)); + assert!(!beta.contains(BETA_OAUTH)); + assert!(!beta.contains(BETA_CLAUDE_CODE)); + } } diff --git a/src/llm/anthropic/params.rs b/src/llm/anthropic/params.rs index 2f45462af..11dcda8ef 100644 --- a/src/llm/anthropic/params.rs +++ b/src/llm/anthropic/params.rs @@ -58,8 +58,9 @@ pub fn build_anthropic_request( model_name: &str, request: &CompletionRequest, thinking_effort: &str, + force_bearer: bool, ) -> AnthropicRequest { - let is_oauth = auth::detect_auth_path(api_key) == AnthropicAuthPath::OAuthToken; + let is_oauth = auth::detect_auth_path(api_key, force_bearer) == AnthropicAuthPath::OAuthToken; let adaptive_thinking = supports_adaptive_thinking(model_name); let retention = cache::resolve_cache_retention(None); let url = messages_url(base_url); @@ -101,7 +102,7 @@ pub fn build_anthropic_request( .header("anthropic-version", "2023-06-01") .header("content-type", "application/json"); - let (builder, auth_path) = auth::apply_auth_headers(builder, api_key, false); + let (builder, auth_path) = auth::apply_auth_headers(builder, api_key, false, force_bearer); let builder = builder.json(&body); AnthropicRequest { diff --git a/src/llm/manager.rs b/src/llm/manager.rs index 4febe761d..245423b01 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -185,6 +185,7 @@ impl LlmManager { base_url: "https://api.anthropic.com".to_string(), api_key: token, name: None, + use_bearer_auth: false, }), (None, None) => Err(LlmError::UnknownProvider("anthropic".to_string()).into()), } @@ -253,6 +254,7 @@ impl LlmManager { base_url: "https://chatgpt.com/backend-api/codex".to_string(), api_key: token, name: None, + use_bearer_auth: false, }), None => Err(LlmError::UnknownProvider("openai-chatgpt".to_string()).into()), } diff --git a/src/llm/model.rs b/src/llm/model.rs index c2a166dd0..8dbbbf675 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -450,6 +450,7 @@ impl SpacebotModel { &self.model_name, &request, effort, + provider_config.use_bearer_auth, ); let is_oauth =