use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use super::LlmProvider;
pub struct AnthropicProvider {
http: reqwest::Client,
api_key: String,
model: String,
max_tokens: u32,
}
#[derive(Serialize)]
struct MessagesRequest<'a> {
model: &'a str,
max_tokens: u32,
messages: Vec<ApiMessage<'a>>,
}
#[derive(Serialize)]
struct ApiMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
}
#[derive(Deserialize)]
struct ContentBlock {
text: Option<String>,
}
impl AnthropicProvider {
pub fn new(api_key: &str, model: &str, max_tokens: u32) -> Self {
Self {
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.expect("failed to build HTTP client"),
api_key: api_key.to_string(),
model: model.to_string(),
max_tokens,
}
}
}
#[async_trait]
impl LlmProvider for AnthropicProvider {
fn name(&self) -> &str {
"anthropic"
}
async fn health_check(&self) -> Result<()> {
let response = self
.http
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.body("{}")
.send()
.await
.context("cannot reach Anthropic API")?;
match response.status() {
s if s == reqwest::StatusCode::UNAUTHORIZED => {
anyhow::bail!("Anthropic API key is invalid (HTTP 401)");
}
s if s == reqwest::StatusCode::FORBIDDEN => {
anyhow::bail!("Anthropic API key lacks permission (HTTP 403)");
}
_ => {
Ok(())
}
}
}
async fn complete(&self, prompt: &str) -> Result<String> {
let body = MessagesRequest {
model: &self.model,
max_tokens: self.max_tokens,
messages: vec![ApiMessage {
role: "user",
content: prompt,
}],
};
let response = self
.http
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await
.context("Failed to connect to Anthropic API")?;
let status = response.status();
let body_text = response
.text()
.await
.context("Failed to read Anthropic response body")?;
if !status.is_success() {
anyhow::bail!(
"Anthropic API error (HTTP {}): {}",
status.as_u16(),
body_text
);
}
let resp: MessagesResponse =
serde_json::from_str(&body_text).context("Failed to parse Anthropic response")?;
let text = resp
.content
.into_iter()
.filter_map(|block| block.text)
.collect::<Vec<_>>()
.join("");
Ok(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn messages_request_serialization() {
let req = MessagesRequest {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: vec![ApiMessage {
role: "user",
content: "hello",
}],
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("claude-sonnet"));
assert!(json.contains("hello"));
}
#[test]
fn response_deserialization() {
let json = r#"{"content":[{"type":"text","text":"Hello world"}]}"#;
let resp: MessagesResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.content[0].text.as_deref(), Some("Hello world"));
}
}