diff --git a/src/api/providers.rs b/src/api/providers.rs index f70c8eac4..6024e8ae3 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -101,96 +101,112 @@ fn build_test_llm_config(provider: &str, credential: &str) -> crate::config::Llm api_type: ApiType::Anthropic, base_url: "https://api.anthropic.com".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "openai" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.openai.com".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "openrouter" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://openrouter.ai/api".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "zhipu" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.z.ai/api/paas/v4".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "groq" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.groq.com/openai".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "together" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.together.xyz".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "fireworks" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.fireworks.ai/inference".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "deepseek" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.deepseek.com".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "xai" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.x.ai".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "mistral" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.mistral.ai".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "gemini" => Some(ProviderConfig { api_type: ApiType::Gemini, base_url: crate::config::GEMINI_PROVIDER_BASE_URL.to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "opencode-zen" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://opencode.ai/zen".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "nvidia" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://integrate.api.nvidia.com".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "minimax" => Some(ProviderConfig { api_type: ApiType::Anthropic, base_url: "https://api.minimax.io/anthropic".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "moonshot" => Some(ProviderConfig { api_type: ApiType::OpenAiCompletions, base_url: "https://api.moonshot.ai".to_string(), api_key: credential.to_string(), + is_auth_token: false, name: None, }), "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(), + is_auth_token: false, name: None, }), _ => None, diff --git a/src/config.rs b/src/config.rs index abb6f8288..49b9e7675 100644 --- a/src/config.rs +++ b/src/config.rs @@ -133,6 +133,8 @@ pub struct ProviderConfig { pub base_url: String, pub api_key: String, pub name: Option, + /// Whether the token came from ANTHROPIC_AUTH_TOKEN (uses Bearer auth) + pub is_auth_token: bool, } /// LLM provider credentials (instance-level). @@ -1802,6 +1804,10 @@ impl Config { /// Load from environment variables only (no config file). pub fn load_from_env(instance_dir: &Path) -> Result { + // Track whether ANTHROPIC_AUTH_TOKEN is being used (for Bearer auth) + let anthropic_is_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() @@ -1837,6 +1843,7 @@ impl Config { base_url, api_key: anthropic_key, name: None, + is_auth_token: anthropic_is_auth_token, }); } @@ -1847,6 +1854,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: OPENAI_PROVIDER_BASE_URL.to_string(), api_key: openai_key, + is_auth_token: false, name: None, }); } @@ -1858,6 +1866,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), api_key: openrouter_key, + is_auth_token: false, name: None, }); } @@ -1869,6 +1878,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: ZHIPU_PROVIDER_BASE_URL.to_string(), api_key: zhipu_key, + is_auth_token: false, name: None, }); } @@ -1880,6 +1890,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: ZAI_CODING_PLAN_BASE_URL.to_string(), api_key: zai_coding_plan_key, + is_auth_token: false, name: None, }); } @@ -1891,6 +1902,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), api_key: opencode_zen_key, + is_auth_token: false, name: None, }); } @@ -1902,6 +1914,7 @@ impl Config { api_type: ApiType::Anthropic, base_url: MINIMAX_PROVIDER_BASE_URL.to_string(), api_key: minimax_key, + is_auth_token: false, name: None, }); } @@ -1913,6 +1926,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: MOONSHOT_PROVIDER_BASE_URL.to_string(), api_key: moonshot_key, + is_auth_token: false, name: None, }); } @@ -1924,6 +1938,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: NVIDIA_PROVIDER_BASE_URL.to_string(), api_key: nvidia_key, + is_auth_token: false, name: None, }); } @@ -1935,6 +1950,7 @@ impl Config { api_type: ApiType::Gemini, base_url: GEMINI_PROVIDER_BASE_URL.to_string(), api_key: gemini_key, + is_auth_token: false, name: None, }); } @@ -1944,6 +1960,7 @@ impl Config { // Env-only routing: check for env overrides on channel/worker models. // SPACEBOT_MODEL overrides all process types at once; specific vars take precedence. + // ANTHROPIC_MODEL sets all anthropic/* models to the specified value. let mut routing = RoutingConfig::default(); if let Ok(model) = std::env::var("SPACEBOT_MODEL") { routing.channel = model.clone(); @@ -1952,6 +1969,19 @@ impl Config { routing.compactor = model.clone(); routing.cortex = model; } + if let Ok(anthropic_model) = std::env::var("ANTHROPIC_MODEL") { + // ANTHROPIC_MODEL sets all anthropic/* routes to the specified model + let channel = format!("anthropic/{}", anthropic_model); + let branch = format!("anthropic/{}", anthropic_model); + let worker = format!("anthropic/{}", anthropic_model); + let compactor = format!("anthropic/{}", anthropic_model); + let cortex = format!("anthropic/{}", anthropic_model); + routing.channel = channel; + routing.branch = branch; + routing.worker = worker; + routing.compactor = compactor; + routing.cortex = cortex; + } if let Ok(channel_model) = std::env::var("SPACEBOT_CHANNEL_MODEL") { routing.channel = channel_model; } @@ -2045,6 +2075,16 @@ impl Config { } } + // Track whether ANTHROPIC_AUTH_TOKEN is being used (for Bearer auth) + let anthropic_is_auth_token = toml + .llm + .anthropic_key + .as_deref() + .and_then(resolve_env_value) + .is_none() + && std::env::var("ANTHROPIC_API_KEY").is_err() + && std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok(); + let mut llm = LlmConfig { anthropic_key: toml .llm @@ -2168,6 +2208,7 @@ impl Config { api_key: resolve_env_value(&config.api_key) .expect("Failed to resolve API key for provider"), name: config.name, + is_auth_token: false, }, ) }) @@ -2184,6 +2225,7 @@ impl Config { base_url, api_key: anthropic_key, name: None, + is_auth_token: anthropic_is_auth_token, }); } @@ -2194,6 +2236,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: OPENAI_PROVIDER_BASE_URL.to_string(), api_key: openai_key, + is_auth_token: false, name: None, }); } @@ -2205,6 +2248,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), api_key: openrouter_key, + is_auth_token: false, name: None, }); } @@ -2216,6 +2260,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: ZHIPU_PROVIDER_BASE_URL.to_string(), api_key: zhipu_key, + is_auth_token: false, name: None, }); } @@ -2227,6 +2272,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: ZAI_CODING_PLAN_BASE_URL.to_string(), api_key: zai_coding_plan_key, + is_auth_token: false, name: None, }); } @@ -2238,6 +2284,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), api_key: opencode_zen_key, + is_auth_token: false, name: None, }); } @@ -2249,6 +2296,7 @@ impl Config { api_type: ApiType::Anthropic, base_url: MINIMAX_PROVIDER_BASE_URL.to_string(), api_key: minimax_key, + is_auth_token: false, name: None, }); } @@ -2260,6 +2308,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: MOONSHOT_PROVIDER_BASE_URL.to_string(), api_key: moonshot_key, + is_auth_token: false, name: None, }); } @@ -2271,6 +2320,7 @@ impl Config { api_type: ApiType::OpenAiCompletions, base_url: NVIDIA_PROVIDER_BASE_URL.to_string(), api_key: nvidia_key, + is_auth_token: false, name: None, }); } @@ -2282,6 +2332,7 @@ impl Config { api_type: ApiType::Gemini, base_url: GEMINI_PROVIDER_BASE_URL.to_string(), api_key: gemini_key, + is_auth_token: false, name: None, }); } diff --git a/src/llm/anthropic/auth.rs b/src/llm/anthropic/auth.rs index 87ae7c5cb..9d6de727e 100644 --- a/src/llm/anthropic/auth.rs +++ b/src/llm/anthropic/auth.rs @@ -88,11 +88,18 @@ pub enum AnthropicAuthPath { ApiKey, /// OAuth token (sk-ant-oat*) — uses Bearer auth with Claude Code identity. OAuthToken, + /// Auth token from ANTHROPIC_AUTH_TOKEN — uses Bearer auth without Claude Code identity. + AuthToken, } /// 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 `is_auth_token` is true (token came from ANTHROPIC_AUTH_TOKEN env var), +/// returns `AuthToken` to use Bearer auth without Claude Code identity headers. +pub fn detect_auth_path(token: &str, is_auth_token: bool) -> AnthropicAuthPath { + if is_auth_token { + AnthropicAuthPath::AuthToken + } else if token.starts_with("sk-ant-oat") { AnthropicAuthPath::OAuthToken } else { AnthropicAuthPath::ApiKey @@ -107,8 +114,9 @@ pub fn apply_auth_headers( builder: RequestBuilder, token: &str, interleaved_thinking: bool, + is_auth_token: bool, ) -> (RequestBuilder, AnthropicAuthPath) { - let auth_path = detect_auth_path(token); + let auth_path = detect_auth_path(token, is_auth_token); let mut beta_parts: Vec<&str> = Vec::new(); let builder = match auth_path { @@ -125,6 +133,10 @@ pub fn apply_auth_headers( .header("user-agent", CLAUDE_CODE_USER_AGENT) .header("x-app", "cli") } + AnthropicAuthPath::AuthToken => { + beta_parts.push(BETA_FINE_GRAINED_STREAMING); + builder.header("Authorization", format!("Bearer {token}")) + } }; if interleaved_thinking { @@ -142,9 +154,17 @@ mod tests { use super::*; fn build_request(token: &str, thinking: bool) -> (reqwest::Request, AnthropicAuthPath) { + build_request_with_auth_token(token, thinking, false) + } + + fn build_request_with_auth_token( + token: &str, + thinking: bool, + is_auth_token: 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, is_auth_token); (builder.build().unwrap(), auth_path) } @@ -247,4 +267,39 @@ mod tests { .unwrap(); assert!(!beta.contains(BETA_INTERLEAVED_THINKING)); } + + #[test] + fn auth_token_uses_bearer_header() { + // ANTHROPIC_AUTH_TOKEN should use Bearer auth even without sk-ant-oat prefix + let (request, auth_path) = build_request_with_auth_token("my-proxy-token", false, true); + assert_eq!(auth_path, AnthropicAuthPath::AuthToken); + assert_eq!( + request.headers().get("Authorization").unwrap(), + "Bearer my-proxy-token" + ); + assert!(request.headers().get("x-api-key").is_none()); + } + + #[test] + fn auth_token_has_no_identity_headers() { + // Auth tokens should not include Claude Code identity headers + let (request, _) = build_request_with_auth_token("my-proxy-token", false, true); + assert!(request.headers().get("user-agent").is_none()); + assert!(request.headers().get("x-app").is_none()); + } + + #[test] + fn auth_token_has_streaming_beta_but_no_oauth_beta() { + // Auth tokens should have fine-grained streaming but not OAuth/Claude Code betas + let (request, _) = build_request_with_auth_token("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..9101edef4 100644 --- a/src/llm/anthropic/params.rs +++ b/src/llm/anthropic/params.rs @@ -58,8 +58,10 @@ pub fn build_anthropic_request( model_name: &str, request: &CompletionRequest, thinking_effort: &str, + is_auth_token: bool, ) -> AnthropicRequest { - let is_oauth = auth::detect_auth_path(api_key) == AnthropicAuthPath::OAuthToken; + let auth_path = auth::detect_auth_path(api_key, is_auth_token); + let is_oauth = auth_path == AnthropicAuthPath::OAuthToken || auth_path == AnthropicAuthPath::AuthToken; let adaptive_thinking = supports_adaptive_thinking(model_name); let retention = cache::resolve_cache_retention(None); let url = messages_url(base_url); @@ -101,7 +103,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, is_auth_token); let builder = builder.json(&body); AnthropicRequest { diff --git a/src/llm/manager.rs b/src/llm/manager.rs index 69fd113ac..c2d4a3999 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -164,6 +164,7 @@ impl LlmManager { base_url: "https://api.anthropic.com".to_string(), api_key: token, name: None, + is_auth_token: false, }), (None, None) => Err(LlmError::UnknownProvider("anthropic".to_string()).into()), } diff --git a/src/llm/model.rs b/src/llm/model.rs index 95d81a914..f89241b4f 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -356,10 +356,11 @@ impl SpacebotModel { &self.model_name, &request, effort, + provider_config.is_auth_token, ); - let is_oauth = - anthropic_request.auth_path == crate::llm::anthropic::AnthropicAuthPath::OAuthToken; + let is_oauth = anthropic_request.auth_path == crate::llm::anthropic::AnthropicAuthPath::OAuthToken + || anthropic_request.auth_path == crate::llm::anthropic::AnthropicAuthPath::AuthToken; let original_tools = anthropic_request.original_tools; let response = anthropic_request