use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use tracing::{debug, error, info, instrument, trace, warn};
use crate::backend::model_macro::define_model_enum;
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;
define_model_enum! {
pub enum AnthropicModel {
ClaudeOpus48 => "claude-opus-4-8",
ClaudeOpus47 => "claude-opus-4-7",
ClaudeSonnet46 => "claude-sonnet-4-6",
ClaudeOpus46 => "claude-opus-4-6",
ClaudeOpus45 => "claude-opus-4-5-20251101",
ClaudeHaiku45 => "claude-haiku-4-5-20251001",
ClaudeSonnet45 => "claude-sonnet-4-5-20250929",
ClaudeOpus41 => "claude-opus-4-1-20250805",
ClaudeOpus4 => "claude-opus-4-20250514",
ClaudeSonnet4 => "claude-sonnet-4-20250514",
}
}
#[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
}
}
#[cfg(feature = "streaming")]
impl AnthropicClient {
fn stream_body(
&self,
prompt: &str,
output_format: Option<serde_json::Value>,
) -> serde_json::Value {
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 max_tokens = effective_max_tokens(self.config.max_tokens, thinking_config.as_ref());
let mut body = serde_json::json!({
"model": self.config.model.as_str(),
"messages": [{ "role": "user", "content": prompt }],
"temperature": effective_temp,
"max_tokens": max_tokens,
"stream": true,
});
if let Some(tc) = thinking_config {
body["thinking"] =
serde_json::json!({ "type": tc.thinking_type, "budget_tokens": tc.budget_tokens });
}
if let Some(of) = output_format {
body["output_format"] = of;
}
body
}
fn send_stream(
&self,
body: serde_json::Value,
) -> impl std::future::Future<Output = Result<reqwest::Response>> + Send + 'static {
let client = self.client.clone();
let api_key = self.config.api_key.clone();
let base_url = self
.config
.base_url
.clone()
.unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
async move {
let url = format!("{}/messages", base_url);
let resp = client
.post(&url)
.header("x-api-key", &api_key)
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", "structured-outputs-2025-11-13")
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| handle_http_error(e, "Anthropic"))?;
check_response_status(resp, "Anthropic").await
}
}
}
#[cfg(feature = "tools")]
#[async_trait]
impl crate::backend::tools::ToolRunner for AnthropicClient {
async fn run_tool_loop(
&self,
system: Option<&str>,
prompt: &str,
toolbox: &crate::backend::tools::Toolbox,
max_iterations: usize,
) -> Result<String> {
let base_url = self
.config
.base_url
.as_deref()
.unwrap_or("https://api.anthropic.com/v1");
crate::backend::tools::run_anthropic_tools(
&self.client,
base_url,
&self.config.api_key,
self.config.model.as_str(),
self.config.temperature,
self.config
.max_tokens
.unwrap_or(DEFAULT_ANTHROPIC_MAX_TOKENS),
system,
prompt,
toolbox,
max_iterations,
)
.await
}
}
#[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))
}
#[cfg(feature = "streaming")]
fn generate_stream<'a>(&'a self, prompt: &'a str) -> crate::backend::streaming::TextStream<'a>
where
Self: Sync,
{
let body = self.stream_body(prompt, None);
crate::backend::streaming::sse_text_stream(
self.send_stream(body),
crate::backend::streaming::anthropic_delta,
)
}
#[cfg(feature = "streaming")]
fn materialize_stream<'a, T>(
&'a self,
prompt: &'a str,
) -> crate::backend::streaming::ObjectStream<'a, T>
where
T: Instructor + DeserializeOwned + Send + 'static,
Self: Sync,
{
let schema_json = prepare_strict_schema(&T::schema());
let output_format = serde_json::json!({
"type": "json_schema",
"schema": schema_json,
});
let body = self.stream_body(prompt, Some(output_format));
crate::backend::streaming::object_stream(
self.send_stream(body),
crate::backend::streaming::anthropic_delta,
)
}
#[cfg(feature = "streaming")]
fn materialize_iter<'a, T>(
&'a self,
prompt: &'a str,
) -> crate::backend::streaming::ItemStream<'a, T>
where
T: Instructor + DeserializeOwned + Send + 'static,
Self: Sync,
{
let item_schema = prepare_strict_schema(&T::schema());
let wrapper = crate::backend::streaming::array_wrapper_schema(item_schema, true);
let output_format = serde_json::json!({ "type": "json_schema", "schema": wrapper });
let body = self.stream_body(prompt, Some(output_format));
crate::backend::streaming::iter_stream(
self.send_stream(body),
crate::backend::streaming::anthropic_delta,
crate::backend::streaming::finalize_item::<T>,
)
}
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);
}
}