feat: add Anthropic setup-token flow
Implements Anthropic setup-token flow from PR #103. All 907 tests pass.
This commit is contained in:
parent
b208cc940e
commit
be135e07cf
2 changed files with 102 additions and 15 deletions
|
|
@ -4,7 +4,7 @@ use reqwest::Client;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct AnthropicProvider {
|
||||
api_key: Option<String>,
|
||||
credential: Option<String>,
|
||||
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<String> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<Box<dyn Provider>> {
|
||||
let resolved_key = resolve_api_key(name, api_key);
|
||||
match name {
|
||||
// ── Primary providers (custom implementations) ───────
|
||||
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(api_key))),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue