use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde_json::{Value, json};
use tracing::{debug, warn};
use super::{LlmProvider, LlmRequest, LlmResponse, error::SmLlmError, pricing};
pub const DEFAULT_ANTHROPIC_BASE: &str = "https://api.anthropic.com";
pub const ENV_ANTHROPIC_BASE_URL: &str = "ANTHROPIC_BASE_URL";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const CONNECT_TIMEOUT_SECS: u64 = 10;
const READ_TIMEOUT_SECS: u64 = 120;
fn build_body(req: &LlmRequest) -> Value {
let messages: Vec<Value> = req
.messages
.iter()
.map(|m| {
let role = if m.role == "assistant" {
"assistant"
} else {
"user"
};
json!({ "role": role, "content": m.content })
})
.collect();
let mut body = json!({
"model": req.model,
"max_tokens": req.max_tokens,
"temperature": req.temperature,
"messages": messages,
});
if !req.system.is_empty() {
body["system"] = Value::String(req.system.clone());
}
body
}
fn parse_response(value: &Value) -> (String, u32, u32) {
let mut text = String::new();
if let Some(blocks) = value.get("content").and_then(|c| c.as_array()) {
for block in blocks {
if block.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(s) = block.get("text").and_then(|t| t.as_str())
{
if !text.is_empty() {
text.push('\n');
}
text.push_str(s);
}
}
}
let (input, output) = value
.get("usage")
.map(|u| {
(
u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
)
})
.unwrap_or((0, 0));
(text, input, output)
}
#[derive(Debug)]
pub struct AnthropicProvider {
api_key: String,
model: String,
base: String,
client: reqwest::Client,
}
impl AnthropicProvider {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Result<Self, SmLlmError> {
let base = std::env::var(ENV_ANTHROPIC_BASE_URL)
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| DEFAULT_ANTHROPIC_BASE.to_string());
Self::with_base_url(api_key, model, base)
}
pub fn with_base_url(
api_key: impl Into<String>,
model: impl Into<String>,
base: impl Into<String>,
) -> Result<Self, SmLlmError> {
let api_key = api_key.into();
if api_key.is_empty() {
return Err(SmLlmError::AccessDenied(
"ANTHROPIC_API_KEY is empty".to_string(),
));
}
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
.timeout(Duration::from_secs(READ_TIMEOUT_SECS))
.build()
.map_err(|e| SmLlmError::Transport(format!("build reqwest client: {e}")))?;
Ok(Self {
api_key,
model: model.into(),
base: base.into().trim_end_matches('/').to_string(),
client,
})
}
}
#[async_trait]
impl LlmProvider for AnthropicProvider {
fn name(&self) -> &str {
"anthropic"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, SmLlmError> {
let url = format!("{}/v1/messages", self.base);
let body = build_body(&req);
let start = Instant::now();
let http_resp = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| SmLlmError::Transport(e.to_string()))?;
let latency_ms = start.elapsed().as_millis() as u64;
let status = http_resp.status();
if !status.is_success() {
let text = http_resp.text().await.unwrap_or_default();
return Err(match status.as_u16() {
401 | 403 => SmLlmError::AccessDenied(text),
404 => SmLlmError::ModelNotFound(format!("model={}: {text}", self.model)),
400 | 422 => SmLlmError::Validation(text),
429 => SmLlmError::RateLimited,
code => SmLlmError::Upstream {
status: code,
body: text,
},
});
}
let value: Value = http_resp.json().await.map_err(|e| {
warn!("failed to parse Anthropic response: {e}");
SmLlmError::Upstream {
status: status.as_u16(),
body: e.to_string(),
}
})?;
let (text, input_tokens, output_tokens) = parse_response(&value);
let model_used = value
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&self.model)
.to_string();
let cost_usd = pricing::estimate_cost_usd(&model_used, input_tokens, output_tokens);
debug!(
provider = "anthropic",
model = %model_used,
input_tokens,
output_tokens,
latency_ms,
cost_usd,
"sm anthropic complete"
);
Ok(LlmResponse {
text,
model: model_used,
input_tokens,
output_tokens,
latency_ms,
cost_usd,
})
}
}
#[cfg(test)]
#[path = "anthropic_tests.rs"]
mod tests;