diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 31d7342..e04af6a 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -4,7 +4,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; pub struct AnthropicProvider { - api_key: Option, + credential: Option, client: Client, } @@ -37,7 +37,10 @@ struct ContentBlock { impl AnthropicProvider { pub fn new(api_key: Option<&str>) -> Self { Self { - api_key: api_key.map(ToString::to_string), + credential: api_key + .map(str::trim) + .filter(|k| !k.is_empty()) + .map(ToString::to_string), client: Client::builder() .timeout(std::time::Duration::from_secs(120)) .connect_timeout(std::time::Duration::from_secs(10)) @@ -45,6 +48,10 @@ impl AnthropicProvider { .unwrap_or_else(|_| Client::new()), } } + + fn is_setup_token(token: &str) -> bool { + token.starts_with("sk-ant-oat01-") + } } #[async_trait] @@ -56,8 +63,10 @@ impl Provider for AnthropicProvider { model: &str, temperature: f64, ) -> anyhow::Result { - let api_key = self.api_key.as_ref().ok_or_else(|| { - anyhow::anyhow!("Anthropic API key not set. Set ANTHROPIC_API_KEY or edit config.toml.") + let credential = self.credential.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)." + ) })?; let request = ChatRequest { @@ -71,15 +80,20 @@ impl Provider for AnthropicProvider { temperature, }; - let response = self + let mut request = self .client .post("https://api.anthropic.com/v1/messages") - .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") - .json(&request) - .send() - .await?; + .json(&request); + + if Self::is_setup_token(credential) { + request = request.header("Authorization", format!("Bearer {credential}")); + } else { + request = request.header("x-api-key", credential); + } + + let response = request.send().await?; if !response.status().is_success() { return Err(super::api_error("Anthropic", response).await); @@ -103,21 +117,27 @@ mod tests { #[test] fn creates_with_key() { let p = AnthropicProvider::new(Some("sk-ant-test123")); - assert!(p.api_key.is_some()); - assert_eq!(p.api_key.as_deref(), Some("sk-ant-test123")); + assert!(p.credential.is_some()); + assert_eq!(p.credential.as_deref(), Some("sk-ant-test123")); } #[test] fn creates_without_key() { let p = AnthropicProvider::new(None); - assert!(p.api_key.is_none()); + assert!(p.credential.is_none()); } #[test] fn creates_with_empty_key() { let p = AnthropicProvider::new(Some("")); - assert!(p.api_key.is_some()); - assert_eq!(p.api_key.as_deref(), Some("")); + assert!(p.credential.is_none()); + } + + #[test] + fn creates_with_whitespace_key() { + let p = AnthropicProvider::new(Some(" sk-ant-test123 ")); + assert!(p.credential.is_some()); + assert_eq!(p.credential.as_deref(), Some("sk-ant-test123")); } #[tokio::test] @@ -129,11 +149,17 @@ mod tests { assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( - err.contains("API key not set"), + err.contains("credentials not set"), "Expected key error, got: {err}" ); } + #[test] + fn setup_token_detection_works() { + assert!(AnthropicProvider::is_setup_token("sk-ant-oat01-abcdef")); + assert!(!AnthropicProvider::is_setup_token("sk-ant-api-key")); + } + #[tokio::test] async fn chat_with_system_fails_without_key() { let p = AnthropicProvider::new(None); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index f1f4177..a40deac 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -90,9 +90,70 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E anyhow::anyhow!("{provider} API error ({status}): {sanitized}") } +/// Resolve API key for a provider from config and environment variables. +/// +/// Resolution order: +/// 1. Explicitly provided `api_key` parameter (trimmed, filtered if empty) +/// 2. Provider-specific environment variable (e.g., `ANTHROPIC_OAUTH_TOKEN`, `OPENROUTER_API_KEY`) +/// 3. Generic fallback variables (`ZEROCLAW_API_KEY`, `API_KEY`) +/// +/// For Anthropic, the provider-specific env var is `ANTHROPIC_OAUTH_TOKEN` (for setup-tokens) +/// followed by `ANTHROPIC_API_KEY` (for regular API keys). +fn resolve_api_key(name: &str, api_key: Option<&str>) -> Option { + if let Some(key) = api_key.map(str::trim).filter(|k| !k.is_empty()) { + return Some(key.to_string()); + } + + let provider_env_candidates: Vec<&str> = match name { + "anthropic" => vec!["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + "openrouter" => vec!["OPENROUTER_API_KEY"], + "openai" => vec!["OPENAI_API_KEY"], + "venice" => vec!["VENICE_API_KEY"], + "groq" => vec!["GROQ_API_KEY"], + "mistral" => vec!["MISTRAL_API_KEY"], + "deepseek" => vec!["DEEPSEEK_API_KEY"], + "xai" | "grok" => vec!["XAI_API_KEY"], + "together" | "together-ai" => vec!["TOGETHER_API_KEY"], + "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY"], + "perplexity" => vec!["PERPLEXITY_API_KEY"], + "cohere" => vec!["COHERE_API_KEY"], + "moonshot" | "kimi" => vec!["MOONSHOT_API_KEY"], + "glm" | "zhipu" => vec!["GLM_API_KEY"], + "minimax" => vec!["MINIMAX_API_KEY"], + "qianfan" | "baidu" => vec!["QIANFAN_API_KEY"], + "zai" | "z.ai" => vec!["ZAI_API_KEY"], + "synthetic" => vec!["SYNTHETIC_API_KEY"], + "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"], + "vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"], + "cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"], + _ => vec![], + }; + + for env_var in provider_env_candidates { + if let Ok(value) = std::env::var(env_var) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + for env_var in ["ZEROCLAW_API_KEY", "API_KEY"] { + if let Ok(value) = std::env::var(env_var) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + /// Factory: create the right provider from config #[allow(clippy::too_many_lines)] pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result> { + let resolved_key = resolve_api_key(name, api_key); match name { // ── Primary providers (custom implementations) ─────── "openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(api_key))),