use crate::error::ProviderError;
use crate::provider::{CompletionConfig, LlmProvider};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fmt;
const API_BASE_URL: &str = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION: &str = "2023-06-01";
pub struct ClaudeProvider {
client: Client,
api_key: String,
model: String,
}
impl fmt::Debug for ClaudeProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClaudeProvider")
.field("model", &self.model)
.field("api_key", &"[REDACTED]")
.finish()
}
}
#[derive(Debug, Serialize)]
pub struct ClaudeRequest {
pub model: String,
pub max_tokens: u32,
pub temperature: f64,
pub system: String,
pub messages: Vec<ClaudeMessage>,
}
#[derive(Debug, Serialize)]
pub struct ClaudeMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
struct ClaudeResponse {
content: Vec<ContentBlock>,
}
#[derive(Debug, Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
type_: String,
text: Option<String>,
}
impl ClaudeProvider {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: Client::new(),
api_key: api_key.into(),
model: model.into(),
}
}
pub fn name(&self) -> &str {
"claude"
}
pub fn model(&self) -> &str {
&self.model
}
pub fn build_request_body(
&self,
system_prompt: &str,
user_prompt: &str,
config: &CompletionConfig,
) -> ClaudeRequest {
ClaudeRequest {
model: self.model.clone(),
max_tokens: config.max_tokens,
temperature: config.temperature,
system: system_prompt.to_string(),
messages: vec![ClaudeMessage {
role: "user".to_string(),
content: user_prompt.to_string(),
}],
}
}
pub fn parse_response(body: &str) -> Result<String, ProviderError> {
let response: ClaudeResponse =
serde_json::from_str(body).map_err(|e| ProviderError::Http {
status: 0,
body: format!("failed to parse response: {e}"),
})?;
response
.content
.into_iter()
.find(|block| block.type_ == "text")
.and_then(|block| block.text)
.ok_or_else(|| ProviderError::Http {
status: 0,
body: "no text content block in response".to_string(),
})
}
pub fn map_status_to_error(status: u16, body: &str) -> ProviderError {
match status {
401 | 403 => ProviderError::Auth {
message: body.to_string(),
},
_ => ProviderError::Http {
status,
body: body.to_string(),
},
}
}
}
#[async_trait::async_trait]
impl LlmProvider for ClaudeProvider {
async fn complete(
&self,
system_prompt: &str,
user_prompt: &str,
config: &CompletionConfig,
) -> Result<String, ProviderError> {
let body = self.build_request_body(system_prompt, user_prompt, config);
let response = self
.client
.post(API_BASE_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| {
if e.is_timeout() {
ProviderError::Timeout {
message: e.to_string(),
}
} else {
ProviderError::Network {
message: e.to_string(),
}
}
})?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
let response_body = response.text().await.unwrap_or_default();
return Err(Self::map_status_to_error(status, &response_body));
}
let response_body = response.text().await.map_err(|e| ProviderError::Network {
message: format!("failed to read response body: {e}"),
})?;
Self::parse_response(&response_body)
}
fn name(&self) -> &str {
"claude"
}
fn model(&self) -> &str {
&self.model
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_claude_provider_new_creates_with_key_and_model() {
let provider = super::ClaudeProvider::new("sk-test-key", "claude-sonnet-4-6");
assert_eq!(provider.name(), "claude");
assert_eq!(provider.model(), "claude-sonnet-4-6");
}
#[test]
fn test_claude_provider_name_returns_claude() {
let provider = super::ClaudeProvider::new("key", "model");
assert_eq!(provider.name(), "claude");
}
#[test]
fn test_claude_provider_model_returns_configured_model() {
let provider = super::ClaudeProvider::new("key", "claude-opus-4-6");
assert_eq!(provider.model(), "claude-opus-4-6");
}
#[test]
fn test_build_request_body_contains_all_required_fields() {
use crate::provider::CompletionConfig;
let provider = super::ClaudeProvider::new("sk-test", "claude-sonnet-4-6");
let config = CompletionConfig::default();
let body = provider.build_request_body("You are helpful", "Hello", &config);
assert_eq!(body.model, "claude-sonnet-4-6");
assert_eq!(body.max_tokens, 4096);
assert!((body.temperature - 0.0).abs() < f64::EPSILON);
assert_eq!(body.system, "You are helpful");
assert_eq!(body.messages.len(), 1);
assert_eq!(body.messages[0].role, "user");
assert_eq!(body.messages[0].content, "Hello");
}
#[test]
fn test_parse_claude_response_extracts_text_content() {
let json = r#"{"content": [{"type": "text", "text": "response text"}], "id": "msg_1", "model": "claude-sonnet-4-6", "role": "assistant"}"#;
let result = super::ClaudeProvider::parse_response(json);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "response text");
}
#[test]
fn test_parse_response_extracts_first_text_block() {
let json = r#"{"content": [{"type": "text", "text": "first"}, {"type": "text", "text": "second"}], "id": "msg_1", "model": "m", "role": "assistant"}"#;
let result = super::ClaudeProvider::parse_response(json);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "first");
}
#[test]
fn test_parse_response_error_when_no_text_block() {
let json = r#"{"content": [], "id": "msg_1", "model": "m", "role": "assistant"}"#;
let result = super::ClaudeProvider::parse_response(json);
assert!(result.is_err());
}
#[test]
fn test_parse_response_error_on_invalid_json() {
let result = super::ClaudeProvider::parse_response("not json");
assert!(result.is_err());
}
#[test]
fn test_map_status_401_to_auth_error() {
let err = super::ClaudeProvider::map_status_to_error(401, "unauthorized");
assert!(matches!(err, crate::error::ProviderError::Auth { .. }));
}
#[test]
fn test_map_status_403_to_auth_error() {
let err = super::ClaudeProvider::map_status_to_error(403, "forbidden");
assert!(matches!(err, crate::error::ProviderError::Auth { .. }));
}
#[test]
fn test_map_status_500_to_http_error() {
let err = super::ClaudeProvider::map_status_to_error(500, "server error");
match err {
crate::error::ProviderError::Http { status, body } => {
assert_eq!(status, 500);
assert_eq!(body, "server error");
}
other => panic!("expected Http, got: {other}"),
}
}
#[test]
fn test_map_status_429_to_http_error() {
let err = super::ClaudeProvider::map_status_to_error(429, "rate limited");
assert!(matches!(
err,
crate::error::ProviderError::Http { status: 429, .. }
));
}
#[test]
fn test_client_is_stored_in_struct() {
let provider = super::ClaudeProvider::new("key", "model");
assert_eq!(provider.name(), "claude");
let provider2 = super::ClaudeProvider::new("key2", "model2");
assert_eq!(provider2.model(), "model2");
}
#[test]
fn test_debug_does_not_expose_api_key() {
let provider = super::ClaudeProvider::new("sk-super-secret-key-12345", "model");
let debug_str = format!("{:?}", provider);
assert!(
!debug_str.contains("sk-super-secret-key-12345"),
"Debug output must not contain API key, got: {debug_str}"
);
}
}