use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::str::FromStr;
use std::time::Duration;
use tracing::{debug, error, info, instrument, trace, warn};
use crate::backend::{
AnthropicMessageContent, ChatMessage, GenerateResult, LLMClient, MaterializeInternalOutput,
MaterializeResult, ModelInfo, ThinkingLevel, TokenUsage, ValidationFailureContext,
build_anthropic_message_content, check_response_status, generate_with_retry_with_history,
handle_http_error, materialize_with_media_with_retry, parse_validate_and_create_output,
prepare_strict_schema,
};
use crate::error::{ApiErrorKind, RStructorError, Result};
use crate::model::Instructor;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnthropicModel {
ClaudeOpus47,
ClaudeSonnet46,
ClaudeOpus46,
ClaudeOpus45,
ClaudeHaiku45,
ClaudeSonnet45,
ClaudeOpus41,
ClaudeOpus4,
ClaudeSonnet4,
Claude37Sonnet,
Claude35Haiku,
Claude3Haiku,
Claude3Opus,
Custom(String),
}
impl AnthropicModel {
pub fn as_str(&self) -> &str {
match self {
AnthropicModel::ClaudeOpus47 => "claude-opus-4-7",
AnthropicModel::ClaudeSonnet46 => "claude-sonnet-4-6",
AnthropicModel::ClaudeOpus46 => "claude-opus-4-6",
AnthropicModel::ClaudeOpus45 => "claude-opus-4-5-20251101",
AnthropicModel::ClaudeHaiku45 => "claude-haiku-4-5-20251001",
AnthropicModel::ClaudeSonnet45 => "claude-sonnet-4-5-20250929",
AnthropicModel::ClaudeOpus41 => "claude-opus-4-1-20250805",
AnthropicModel::ClaudeOpus4 => "claude-opus-4-20250514",
AnthropicModel::ClaudeSonnet4 => "claude-sonnet-4-20250514",
AnthropicModel::Claude37Sonnet => "claude-3-7-sonnet-20250219",
AnthropicModel::Claude35Haiku => "claude-3-5-haiku-20241022",
AnthropicModel::Claude3Haiku => "claude-3-haiku-20240307",
AnthropicModel::Claude3Opus => "claude-3-opus-20240229",
AnthropicModel::Custom(name) => name,
}
}
pub fn from_string(name: impl Into<String>) -> Self {
let name = name.into();
match name.as_str() {
"claude-opus-4-7" => AnthropicModel::ClaudeOpus47,
"claude-sonnet-4-6" => AnthropicModel::ClaudeSonnet46,
"claude-opus-4-6" => AnthropicModel::ClaudeOpus46,
"claude-opus-4-5-20251101" => AnthropicModel::ClaudeOpus45,
"claude-haiku-4-5-20251001" => AnthropicModel::ClaudeHaiku45,
"claude-sonnet-4-5-20250929" => AnthropicModel::ClaudeSonnet45,
"claude-opus-4-1-20250805" => AnthropicModel::ClaudeOpus41,
"claude-opus-4-20250514" => AnthropicModel::ClaudeOpus4,
"claude-sonnet-4-20250514" => AnthropicModel::ClaudeSonnet4,
"claude-3-7-sonnet-20250219" => AnthropicModel::Claude37Sonnet,
"claude-3-5-haiku-20241022" => AnthropicModel::Claude35Haiku,
"claude-3-haiku-20240307" => AnthropicModel::Claude3Haiku,
"claude-3-opus-20240229" => AnthropicModel::Claude3Opus,
_ => AnthropicModel::Custom(name),
}
}
}
impl FromStr for AnthropicModel {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(AnthropicModel::from_string(s))
}
}
impl From<&str> for AnthropicModel {
fn from(s: &str) -> Self {
AnthropicModel::from_string(s)
}
}
impl From<String> for AnthropicModel {
fn from(s: String) -> Self {
AnthropicModel::from_string(s)
}
}
#[derive(Debug, Clone)]
pub struct AnthropicConfig {
pub api_key: String,
pub model: AnthropicModel,
pub temperature: f32,
pub max_tokens: Option<u32>,
pub timeout: Option<Duration>,
pub max_retries: Option<usize>,
pub base_url: Option<String>,
pub thinking_level: Option<ThinkingLevel>,
}
#[derive(Clone)]
pub struct AnthropicClient {
config: AnthropicConfig,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct AnthropicMessage {
role: String,
content: AnthropicMessageContent,
}
#[derive(Debug, Serialize)]
struct OutputFormat {
#[serde(rename = "type")]
format_type: String,
schema: Value,
}
#[derive(Debug, Serialize)]
struct CompletionRequest {
model: String,
messages: Vec<AnthropicMessage>,
temperature: f32,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<ClaudeThinkingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
output_format: Option<OutputFormat>,
}
#[derive(Debug, Serialize)]
struct ClaudeThinkingConfig {
#[serde(rename = "type")]
thinking_type: String,
budget_tokens: u32,
}
const DEFAULT_ANTHROPIC_MAX_TOKENS: u32 = 1024;
fn effective_max_tokens(
configured_max_tokens: Option<u32>,
thinking_config: Option<&ClaudeThinkingConfig>,
) -> u32 {
let configured = configured_max_tokens.unwrap_or(DEFAULT_ANTHROPIC_MAX_TOKENS);
match thinking_config {
Some(thinking) if configured <= thinking.budget_tokens => {
let required_min = thinking.budget_tokens.saturating_add(1);
warn!(
configured_max_tokens = configured,
thinking_budget_tokens = thinking.budget_tokens,
adjusted_max_tokens = required_min,
"Adjusted max_tokens to satisfy Anthropic requirement: max_tokens must be greater than thinking.budget_tokens"
);
required_min
}
_ => configured,
}
}
#[derive(Debug, Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
block_type: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct UsageInfo {
input_tokens: u64,
output_tokens: u64,
}
#[derive(Debug, Deserialize)]
struct CompletionResponse {
content: Vec<ContentBlock>,
model: Option<String>,
#[serde(default)]
usage: Option<UsageInfo>,
}
impl AnthropicClient {
#[instrument(name = "anthropic_client_new", skip(api_key), fields(model = ?AnthropicModel::ClaudeSonnet46))]
pub fn new(api_key: impl Into<String>) -> Result<Self> {
let api_key = api_key.into();
if api_key.is_empty() {
return Err(RStructorError::api_error(
"Anthropic",
ApiErrorKind::AuthenticationFailed,
));
}
info!("Creating new Anthropic client");
trace!("API key length: {}", api_key.len());
let config = AnthropicConfig {
api_key,
model: AnthropicModel::ClaudeSonnet46, temperature: 0.0,
max_tokens: None,
timeout: None, max_retries: Some(3), base_url: None, thinking_level: None, };
debug!("Anthropic client created with default configuration");
Ok(Self {
config,
client: reqwest::Client::new(),
})
}
#[instrument(name = "anthropic_client_from_env", fields(model = ?AnthropicModel::ClaudeSonnet46))]
pub fn from_env() -> Result<Self> {
let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
RStructorError::api_error("Anthropic", ApiErrorKind::AuthenticationFailed)
})?;
info!("Creating new Anthropic client from environment variable");
trace!("API key length: {}", api_key.len());
let config = AnthropicConfig {
api_key,
model: AnthropicModel::ClaudeSonnet46, temperature: 0.0,
max_tokens: None,
timeout: None, max_retries: Some(3), base_url: None, thinking_level: None, };
debug!("Anthropic client created with default configuration");
Ok(Self {
config,
client: reqwest::Client::new(),
})
}
}
impl AnthropicClient {
async fn materialize_internal<T>(
&self,
messages: &[ChatMessage],
) -> std::result::Result<
MaterializeInternalOutput<T>,
(RStructorError, Option<ValidationFailureContext>),
>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
info!("Generating structured response with Anthropic (native structured outputs)");
let schema = T::schema();
trace!("Retrieved JSON schema for type");
let schema_json = prepare_strict_schema(&schema);
let api_messages: Vec<AnthropicMessage> = messages
.iter()
.map(|msg| {
Ok(AnthropicMessage {
role: msg.role.as_str().to_string(),
content: build_anthropic_message_content(msg)?,
})
})
.collect::<Result<Vec<_>>>()
.map_err(|e| (e, None))?;
let is_thinking_model = self.config.model.as_str().contains("sonnet-4")
|| self.config.model.as_str().contains("opus-4");
let thinking_config = self.config.thinking_level.and_then(|level| {
if is_thinking_model && level.claude_thinking_enabled() {
Some(ClaudeThinkingConfig {
thinking_type: "enabled".to_string(),
budget_tokens: level.claude_budget_tokens(),
})
} else {
None
}
});
let effective_temp = if thinking_config.is_some() {
1.0
} else {
self.config.temperature
};
let output_format = OutputFormat {
format_type: "json_schema".to_string(),
schema: schema_json,
};
debug!(
"Building Anthropic API request with structured outputs (history_len={})",
api_messages.len()
);
let request = CompletionRequest {
model: self.config.model.as_str().to_string(),
messages: api_messages,
temperature: effective_temp,
max_tokens: effective_max_tokens(self.config.max_tokens, thinking_config.as_ref()),
thinking: thinking_config,
output_format: Some(output_format),
};
debug!(
model = %self.config.model.as_str(),
max_tokens = request.max_tokens,
"Sending request to Anthropic API with structured outputs"
);
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.anthropic.com/v1");
let url = format!("{}/messages", base_url);
debug!(url = %url, "Using Anthropic API endpoint");
let response = self
.client
.post(&url)
.header("x-api-key", &self.config.api_key)
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", "structured-outputs-2025-11-13")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.map_err(|e| (handle_http_error(e, "Anthropic"), None))?;
let response = check_response_status(response, "Anthropic")
.await
.map_err(|e| (e, None))?;
debug!("Successfully received response from Anthropic");
let completion: CompletionResponse = response.json().await.map_err(|e| {
error!(error = %e, "Failed to parse JSON response from Anthropic");
(RStructorError::from(e), None)
})?;
let model_name = completion
.model
.clone()
.unwrap_or_else(|| self.config.model.as_str().to_string());
let usage = completion
.usage
.as_ref()
.map(|u| TokenUsage::new(model_name.clone(), u.input_tokens, u.output_tokens));
let raw_response = match completion
.content
.iter()
.find(|block| block.block_type == "text")
.map(|block| block.text.clone())
{
Some(text) => {
debug!(
content_len = text.len(),
"Successfully extracted text content from response"
);
text
}
None => {
error!("No text content in Anthropic response");
return Err((
RStructorError::api_error(
"Anthropic",
ApiErrorKind::UnexpectedResponse {
details: "No text content in response".to_string(),
},
),
None,
));
}
};
trace!(json = %raw_response, "Parsing structured output response");
parse_validate_and_create_output(raw_response, usage)
}
}
crate::impl_client_builder_methods! {
client_type: AnthropicClient,
config_type: AnthropicConfig,
model_type: AnthropicModel,
provider_name: "Anthropic"
}
impl AnthropicClient {
#[tracing::instrument(skip(self, base_url))]
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
let base_url_str = base_url.into();
tracing::debug!(
previous_base_url = ?self.config.base_url,
new_base_url = %base_url_str,
"Setting custom base URL"
);
self.config.base_url = Some(base_url_str);
self
}
#[tracing::instrument(skip(self))]
pub fn thinking_level(mut self, level: ThinkingLevel) -> Self {
tracing::debug!(
previous_level = ?self.config.thinking_level,
new_level = ?level,
"Setting thinking level"
);
self.config.thinking_level = Some(level);
self
}
}
#[async_trait]
impl LLMClient for AnthropicClient {
fn from_env() -> Result<Self> {
Self::from_env()
}
#[instrument(
name = "anthropic_materialize",
skip(self, prompt),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn materialize<T>(&self, prompt: &str) -> Result<T>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
let output = generate_with_retry_with_history(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
self.config.max_retries,
)
.await?;
Ok(output.data)
}
#[instrument(
name = "anthropic_materialize_with_media",
skip(self, prompt, media),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len(),
media_len = media.len()
)
)]
async fn materialize_with_media<T>(&self, prompt: &str, media: &[super::MediaFile]) -> Result<T>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
materialize_with_media_with_retry(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
media,
self.config.max_retries,
)
.await
}
#[instrument(
name = "anthropic_materialize_with_metadata",
skip(self, prompt),
fields(
type_name = std::any::type_name::<T>(),
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn materialize_with_metadata<T>(&self, prompt: &str) -> Result<MaterializeResult<T>>
where
T: Instructor + DeserializeOwned + Send + 'static,
{
let output = generate_with_retry_with_history(
|messages: Vec<ChatMessage>| {
let this = self;
async move { this.materialize_internal::<T>(&messages).await }
},
prompt,
self.config.max_retries,
)
.await?;
Ok(MaterializeResult::new(output.data, output.usage))
}
#[instrument(
name = "anthropic_generate",
skip(self, prompt),
fields(
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn generate(&self, prompt: &str) -> Result<String> {
let result = self.generate_with_metadata(prompt).await?;
Ok(result.text)
}
#[instrument(
name = "anthropic_generate_with_metadata",
skip(self, prompt),
fields(
model = %self.config.model.as_str(),
prompt_len = prompt.len()
)
)]
async fn generate_with_metadata(&self, prompt: &str) -> Result<GenerateResult> {
info!("Generating raw text response with Anthropic");
let is_thinking_model = self.config.model.as_str().contains("sonnet-4")
|| self.config.model.as_str().contains("opus-4");
let thinking_config = self.config.thinking_level.and_then(|level| {
if is_thinking_model && level.claude_thinking_enabled() {
Some(ClaudeThinkingConfig {
thinking_type: "enabled".to_string(),
budget_tokens: level.claude_budget_tokens(),
})
} else {
None
}
});
let effective_temp = if thinking_config.is_some() {
1.0
} else {
self.config.temperature
};
debug!("Building Anthropic API request for text generation");
let request = CompletionRequest {
model: self.config.model.as_str().to_string(),
messages: vec![AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::Text(prompt.to_string()),
}],
temperature: effective_temp,
max_tokens: effective_max_tokens(self.config.max_tokens, thinking_config.as_ref()),
thinking: thinking_config,
output_format: None, };
debug!(
model = %self.config.model.as_str(),
max_tokens = request.max_tokens,
"Sending request to Anthropic API"
);
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.anthropic.com/v1");
let url = format!("{}/messages", base_url);
debug!(url = %url, "Using Anthropic API endpoint");
let response = self
.client
.post(&url)
.header("x-api-key", &self.config.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.map_err(|e| handle_http_error(e, "Anthropic"))?;
let response = check_response_status(response, "Anthropic").await?;
debug!("Successfully received response from Anthropic");
let completion: CompletionResponse = response.json().await.map_err(|e| {
error!(error = %e, "Failed to parse JSON response from Anthropic");
e
})?;
let model_name = completion
.model
.clone()
.unwrap_or_else(|| self.config.model.as_str().to_string());
let usage = completion
.usage
.as_ref()
.map(|u| TokenUsage::new(model_name, u.input_tokens, u.output_tokens));
debug!("Extracting text content from response blocks");
let content: String = completion
.content
.iter()
.filter(|block| block.block_type == "text")
.map(|block| block.text.clone())
.collect::<Vec<String>>()
.join("");
if content.is_empty() {
error!("No text content in Anthropic response");
return Err(RStructorError::api_error(
"Anthropic",
ApiErrorKind::UnexpectedResponse {
details: "No text content in response".to_string(),
},
));
}
debug!(
content_len = content.len(),
"Successfully extracted text content"
);
Ok(GenerateResult::new(content, usage))
}
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.anthropic.com/v1");
let url = format!("{}/models", base_url);
debug!(url = %url, "Fetching available models from Anthropic");
let response = self
.client
.get(&url)
.header("x-api-key", &self.config.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.send()
.await
.map_err(|e| handle_http_error(e, "Anthropic"))?;
let response = check_response_status(response, "Anthropic").await?;
let json: serde_json::Value = response.json().await.map_err(|e| {
error!(error = %e, "Failed to parse models response from Anthropic");
e
})?;
let models = json
.get("data")
.and_then(|data| data.as_array())
.map(|models_array| {
models_array
.iter()
.filter_map(|model| {
let id = model.get("id").and_then(|id| id.as_str())?;
if id.starts_with("claude-") {
let display_name = model
.get("display_name")
.and_then(|n| n.as_str())
.map(|s| s.to_string());
Some(ModelInfo {
id: id.to_string(),
name: display_name,
description: None,
})
} else {
None
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
debug!(count = models.len(), "Fetched Anthropic models");
Ok(models)
}
}
#[cfg(test)]
mod tests {
use super::{ClaudeThinkingConfig, DEFAULT_ANTHROPIC_MAX_TOKENS, effective_max_tokens};
fn thinking_config_with_budget(budget_tokens: u32) -> ClaudeThinkingConfig {
ClaudeThinkingConfig {
thinking_type: "enabled".to_string(),
budget_tokens,
}
}
#[test]
fn effective_max_tokens_uses_default_without_thinking() {
let result = effective_max_tokens(None, None);
assert_eq!(result, DEFAULT_ANTHROPIC_MAX_TOKENS);
}
#[test]
fn effective_max_tokens_uses_configured_without_thinking() {
let result = effective_max_tokens(Some(2048), None);
assert_eq!(result, 2048);
}
#[test]
fn effective_max_tokens_adjusts_default_when_thinking_budget_is_higher() {
let thinking = thinking_config_with_budget(2048);
let result = effective_max_tokens(None, Some(&thinking));
assert_eq!(result, 2049);
}
#[test]
fn effective_max_tokens_adjusts_when_configured_equals_budget() {
let thinking = thinking_config_with_budget(4096);
let result = effective_max_tokens(Some(4096), Some(&thinking));
assert_eq!(result, 4097);
}
#[test]
fn effective_max_tokens_keeps_configured_when_already_valid() {
let thinking = thinking_config_with_budget(2048);
let result = effective_max_tokens(Some(8192), Some(&thinking));
assert_eq!(result, 8192);
}
#[test]
fn effective_max_tokens_saturates_on_extreme_budget() {
let thinking = thinking_config_with_budget(u32::MAX);
let result = effective_max_tokens(None, Some(&thinking));
assert_eq!(result, u32::MAX);
}
}