use std::{collections::HashMap, sync::Arc};
use crate::{
FunctionCall, ToolCall,
builder::{LLMBackend, LLMBuilder},
chat::{
ChatMessage, ChatProvider, ChatResponse, ChatRole, MessageType, StreamChunk,
StructuredOutputFormat, Tool, ToolChoice, Usage,
},
completion::{CompletionProvider, CompletionRequest, CompletionResponse},
config::resolve_request_timeout,
embedding::EmbeddingProvider,
error::LLMError,
http::ensure_success,
models::{ModelListRawEntry, ModelListRequest, ModelListResponse, ModelsProvider},
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use chrono::{DateTime, Utc};
use futures::stream::Stream;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug)]
pub struct Anthropic {
pub api_key: String,
pub model: String,
pub max_tokens: u32,
pub temperature: f32,
pub timeout_seconds: u64,
pub top_p: Option<f32>,
pub top_k: Option<u32>,
pub tool_choice: Option<ToolChoice>,
pub reasoning: bool,
pub thinking_budget_tokens: Option<u32>,
client: Client,
}
#[derive(Serialize, Debug)]
struct AnthropicTool<'a> {
name: &'a str,
description: &'a str,
#[serde(rename = "input_schema")]
schema: &'a serde_json::Value,
}
#[derive(Serialize, Debug)]
struct ThinkingConfig {
#[serde(rename = "type")]
thinking_type: String,
budget_tokens: u32,
}
#[derive(Serialize, Debug)]
struct AnthropicOutputFormat {
#[serde(rename = "type")]
format_type: &'static str,
schema: serde_json::Value,
}
#[derive(Serialize, Debug)]
struct AnthropicOutputConfig {
format: AnthropicOutputFormat,
}
fn build_output_config(
json_schema: Option<StructuredOutputFormat>,
) -> Option<AnthropicOutputConfig> {
json_schema.and_then(|s| {
s.schema.map(|sch| AnthropicOutputConfig {
format: AnthropicOutputFormat {
format_type: "json_schema",
schema: sch,
},
})
})
}
#[derive(Serialize, Debug)]
struct AnthropicCompleteRequest<'a> {
messages: Vec<AnthropicMessage<'a>>,
model: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_k: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<AnthropicTool<'a>>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<ThinkingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
output_config: Option<AnthropicOutputConfig>,
}
#[derive(Serialize, Debug)]
struct AnthropicMessage<'a> {
role: &'a str,
content: Vec<MessageContent<'a>>,
}
#[derive(Serialize, Debug)]
struct MessageContent<'a> {
#[serde(rename = "type")]
message_type: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
image_url: Option<ImageUrlContent<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<ImageSource<'a>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "id")]
tool_use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "name")]
tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "input")]
tool_input: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none", rename = "tool_use_id")]
tool_result_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "content")]
tool_output: Option<String>,
}
#[derive(Serialize, Debug)]
struct ImageUrlContent<'a> {
url: &'a str,
}
#[derive(Serialize, Debug)]
struct ImageSource<'a> {
#[serde(rename = "type")]
source_type: &'a str,
media_type: &'a str,
data: String,
}
#[derive(Deserialize, Debug)]
struct AnthropicCompleteResponse {
content: Vec<AnthropicContent>,
usage: Option<AnthropicUsage>,
}
#[derive(Deserialize, Debug)]
struct AnthropicUsage {
input_tokens: u32,
output_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
cache_creation_input_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
cache_read_input_tokens: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug)]
struct AnthropicContent {
text: Option<String>,
#[serde(rename = "type")]
content_type: Option<String>,
thinking: Option<String>,
name: Option<String>,
input: Option<serde_json::Value>,
id: Option<String>,
}
#[derive(Deserialize, Debug)]
struct AnthropicStreamResponse {
#[serde(rename = "type")]
response_type: String,
index: Option<usize>,
content_block: Option<AnthropicStreamContentBlock>,
delta: Option<AnthropicDelta>,
}
#[derive(Deserialize, Debug)]
struct AnthropicStreamContentBlock {
#[serde(rename = "type")]
block_type: String,
id: Option<String>,
name: Option<String>,
#[allow(dead_code)]
text: Option<String>,
}
#[derive(Deserialize, Debug)]
struct AnthropicDelta {
#[serde(rename = "type")]
delta_type: Option<String>,
text: Option<String>,
partial_json: Option<String>,
stop_reason: Option<String>,
}
impl std::fmt::Display for AnthropicCompleteResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let rendered = self
.content
.iter()
.map(|content| match content.content_type.as_deref() {
Some("tool_use") => format!(
"{{\n \"name\": {}, \"input\": {}\n}}",
content.name.clone().unwrap_or_default(),
content.input.clone().unwrap_or(serde_json::Value::Null)
),
Some("thinking") => content.thinking.clone().unwrap_or_default(),
_ => content.text.clone().unwrap_or_default(),
})
.collect::<Vec<_>>()
.join("\n");
write!(f, "{rendered}")
}
}
impl ChatResponse for AnthropicCompleteResponse {
fn text(&self) -> Option<String> {
Some(
self.content
.iter()
.filter_map(|c| {
if c.content_type == Some("text".to_string()) || c.content_type.is_none() {
c.text.clone()
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n"),
)
}
fn thinking(&self) -> Option<String> {
self.content
.iter()
.find(|c| c.content_type == Some("thinking".to_string()))
.and_then(|c| c.thinking.clone())
}
fn tool_calls(&self) -> Option<Vec<ToolCall>> {
match self
.content
.iter()
.filter_map(|c| {
if c.content_type == Some("tool_use".to_string()) {
Some(ToolCall {
id: c.id.clone().unwrap_or_default(),
call_type: "function".to_string(),
function: FunctionCall {
name: c.name.clone().unwrap_or_default(),
arguments: serde_json::to_string(
&c.input.clone().unwrap_or(serde_json::Value::Null),
)
.unwrap_or_default(),
},
})
} else {
None
}
})
.collect::<Vec<ToolCall>>()
{
v if v.is_empty() => None,
v => Some(v),
}
}
fn usage(&self) -> Option<Usage> {
self.usage.as_ref().map(|anthropic_usage| {
let cached_tokens = anthropic_usage.cache_creation_input_tokens.unwrap_or(0)
+ anthropic_usage.cache_read_input_tokens.unwrap_or(0);
Usage {
prompt_tokens: anthropic_usage.input_tokens,
completion_tokens: anthropic_usage.output_tokens,
total_tokens: anthropic_usage.input_tokens + anthropic_usage.output_tokens,
completion_tokens_details: None,
prompt_tokens_details: if cached_tokens > 0 {
Some(crate::chat::PromptTokensDetails {
cached_tokens: Some(cached_tokens),
audio_tokens: None,
})
} else {
None
},
}
})
}
}
impl Anthropic {
fn convert_messages_to_anthropic<'a>(
messages: &'a [ChatMessage],
) -> Result<Vec<AnthropicMessage<'a>>, LLMError> {
let anthropic_messages: Vec<AnthropicMessage<'a>> = messages
.iter()
.filter(|m| m.role != ChatRole::System)
.map(|m| AnthropicMessage {
role: match m.role {
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::System => unreachable!("system messages are filtered before mapping"),
ChatRole::Tool => "user",
},
content: match &m.message_type {
MessageType::Text => vec![MessageContent {
message_type: Some("text"),
text: Some(&m.content),
image_url: None,
source: None,
tool_use_id: None,
tool_input: None,
tool_name: None,
tool_result_id: None,
tool_output: None,
}],
MessageType::Pdf(raw_bytes) => {
vec![MessageContent {
message_type: Some("document"),
text: None,
image_url: None,
source: Some(ImageSource {
source_type: "base64",
media_type: "application/pdf",
data: BASE64.encode(raw_bytes),
}),
tool_use_id: None,
tool_input: None,
tool_name: None,
tool_result_id: None,
tool_output: None,
}]
}
MessageType::Image((image_mime, raw_bytes)) => {
vec![MessageContent {
message_type: Some("image"),
text: None,
image_url: None,
source: Some(ImageSource {
source_type: "base64",
media_type: image_mime.mime_type(),
data: BASE64.encode(raw_bytes),
}),
tool_use_id: None,
tool_input: None,
tool_name: None,
tool_result_id: None,
tool_output: None,
}]
}
MessageType::ImageURL(url) => vec![MessageContent {
message_type: Some("image_url"),
text: None,
image_url: Some(ImageUrlContent { url }),
source: None,
tool_use_id: None,
tool_input: None,
tool_name: None,
tool_result_id: None,
tool_output: None,
}],
MessageType::ToolUse(calls) => calls
.iter()
.map(|c| MessageContent {
message_type: Some("tool_use"),
text: None,
image_url: None,
source: None,
tool_use_id: Some(c.id.clone()),
tool_input: Some(
serde_json::from_str(&c.function.arguments)
.unwrap_or_else(|_| c.function.arguments.clone().into()),
),
tool_name: Some(c.function.name.clone()),
tool_result_id: None,
tool_output: None,
})
.collect(),
MessageType::ToolResult(responses) => responses
.iter()
.map(|r| MessageContent {
message_type: Some("tool_result"),
text: None,
image_url: None,
source: None,
tool_use_id: None,
tool_input: None,
tool_name: None,
tool_result_id: Some(r.id.clone()),
tool_output: Some(r.function.arguments.clone()),
})
.collect(),
},
})
.collect();
if anthropic_messages.is_empty() {
return Err(LLMError::invalid_request(
"At least one non-system message is required".to_string(),
));
}
Ok(anthropic_messages)
}
fn build_stream_request<'a>(
&'a self,
messages: &'a [ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<AnthropicCompleteRequest<'a>, LLMError> {
let anthropic_messages = Self::convert_messages_to_anthropic(messages)?;
let system_message = messages
.iter()
.find(|msg| msg.role == ChatRole::System)
.map(|msg| msg.content.as_str());
Ok(AnthropicCompleteRequest {
messages: anthropic_messages,
model: &self.model,
max_tokens: Some(self.max_tokens),
temperature: Some(self.temperature),
system: system_message,
stream: Some(true),
top_p: self.top_p,
top_k: self.top_k,
tools: None,
tool_choice: None,
thinking: None,
output_config: build_output_config(json_schema),
})
}
fn prepare_tools_and_choice<'a>(
tools: Option<&'a [Tool]>,
tool_choice: &Option<ToolChoice>,
) -> (
Option<Vec<AnthropicTool<'a>>>,
Option<HashMap<String, String>>,
) {
let anthropic_tools = tools.map(|slice| {
slice
.iter()
.map(|tool| AnthropicTool {
name: &tool.function.name,
description: &tool.function.description,
schema: &tool.function.parameters,
})
.collect::<Vec<_>>()
});
let tool_choice_map = match tool_choice {
Some(ToolChoice::Auto) => {
Some(HashMap::from([("type".to_string(), "auto".to_string())]))
}
Some(ToolChoice::Any) => Some(HashMap::from([("type".to_string(), "any".to_string())])),
Some(ToolChoice::Tool(tool_name)) => Some(HashMap::from([
("type".to_string(), "tool".to_string()),
("name".to_string(), tool_name.clone()),
])),
Some(ToolChoice::None) => {
Some(HashMap::from([("type".to_string(), "none".to_string())]))
}
None => None,
};
let final_tool_choice = if anthropic_tools.is_some() {
tool_choice_map
} else {
None
};
(anthropic_tools, final_tool_choice)
}
#[allow(clippy::too_many_arguments)]
pub fn new(
api_key: impl Into<String>,
model: Option<String>,
max_tokens: Option<u32>,
temperature: Option<f32>,
timeout_seconds: Option<u64>,
top_p: Option<f32>,
top_k: Option<u32>,
tool_choice: Option<ToolChoice>,
reasoning: Option<bool>,
thinking_budget_tokens: Option<u32>,
) -> Self {
let timeout_seconds = resolve_request_timeout(timeout_seconds);
let client = Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
.build()
.expect("Failed to build reqwest Client");
Self {
api_key: api_key.into(),
model: model.unwrap_or_else(|| "claude-3-sonnet-20240229".to_string()),
max_tokens: max_tokens.unwrap_or(300),
temperature: temperature.unwrap_or(0.7),
timeout_seconds,
top_p,
top_k,
tool_choice,
reasoning: reasoning.unwrap_or(false),
thinking_budget_tokens,
client,
}
}
}
#[async_trait]
impl ChatProvider for Anthropic {
async fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
if self.api_key.is_empty() {
return Err(LLMError::missing_api_key(
"Missing Anthropic API key".to_string(),
));
}
let anthropic_messages = Self::convert_messages_to_anthropic(messages)?;
let (anthropic_tools, final_tool_choice) =
Self::prepare_tools_and_choice(tools, &self.tool_choice);
let thinking = if self.reasoning {
Some(ThinkingConfig {
thinking_type: "enabled".to_string(),
budget_tokens: self.thinking_budget_tokens.unwrap_or(16000),
})
} else {
None
};
let output_config = build_output_config(json_schema);
let system_message = messages
.iter()
.find(|msg| msg.role == ChatRole::System)
.map(|msg| msg.content.as_str());
let req_body = AnthropicCompleteRequest {
messages: anthropic_messages,
model: &self.model,
max_tokens: Some(self.max_tokens),
temperature: Some(self.temperature),
system: system_message,
stream: Some(false),
top_p: self.top_p,
top_k: self.top_k,
tools: anthropic_tools,
tool_choice: final_tool_choice,
thinking,
output_config,
};
let request = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("Content-Type", "application/json")
.header("anthropic-version", "2023-06-01")
.json(&req_body);
if log::log_enabled!(log::Level::Trace) {
log::trace!(
"{}",
crate::request_diagnostics::summarize_json_request(
"Anthropic",
"chat request",
&req_body
)
);
}
log::debug!("Anthropic request: POST /v1/messages");
let resp = request.send().await?;
log::debug!("Anthropic HTTP status: {}", resp.status());
let resp = ensure_success(resp, "Anthropic").await?;
let body = resp.text().await?;
let json_resp: AnthropicCompleteResponse =
serde_json::from_str(&body).map_err(|e| LLMError::ResponseFormatError {
message: format!("Failed to decode Anthropic response: {e}"),
raw_response: body,
})?;
Ok(Box::new(json_resp))
}
async fn chat(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_with_tools(messages, None, json_schema).await
}
async fn chat_stream(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<std::pin::Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError>
{
if self.api_key.is_empty() {
return Err(LLMError::missing_api_key(
"Missing Anthropic API key".to_string(),
));
}
let req_body = self.build_stream_request(messages, json_schema)?;
let request = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("Content-Type", "application/json")
.header("anthropic-version", "2023-06-01")
.json(&req_body);
let response = request.send().await?;
let response = ensure_success(response, "Anthropic").await?;
Ok(crate::chat::create_sse_stream(
response,
parse_anthropic_sse_chunk,
))
}
async fn chat_stream_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<std::pin::Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>>, LLMError>
{
if self.api_key.is_empty() {
return Err(LLMError::missing_api_key(
"Missing Anthropic API key".to_string(),
));
}
let anthropic_messages = Self::convert_messages_to_anthropic(messages)?;
let (anthropic_tools, final_tool_choice) =
Self::prepare_tools_and_choice(tools, &self.tool_choice);
let system_message = messages
.iter()
.find(|msg| msg.role == ChatRole::System)
.map(|msg| msg.content.as_str());
let req_body = AnthropicCompleteRequest {
messages: anthropic_messages,
model: &self.model,
max_tokens: Some(self.max_tokens),
temperature: Some(self.temperature),
system: system_message,
stream: Some(true),
top_p: self.top_p,
top_k: self.top_k,
tools: anthropic_tools,
tool_choice: final_tool_choice,
thinking: None, output_config: build_output_config(json_schema),
};
let request = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("Content-Type", "application/json")
.header("anthropic-version", "2023-06-01")
.json(&req_body);
if log::log_enabled!(log::Level::Trace) {
log::trace!(
"{}",
crate::request_diagnostics::summarize_json_request(
"Anthropic",
"streaming tools request",
&req_body
)
);
}
log::debug!("Anthropic request: POST /v1/messages (streaming with tools)");
let response = request.send().await?;
log::debug!("Anthropic HTTP status: {}", response.status());
let response = ensure_success(response, "Anthropic").await?;
Ok(create_anthropic_tool_stream(response))
}
fn model(&self) -> &str {
&self.model
}
}
fn create_anthropic_tool_stream(
response: reqwest::Response,
) -> std::pin::Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>> {
use futures::stream::StreamExt;
let stream = response
.bytes_stream()
.scan(
(String::default(), Vec::default(), HashMap::default()),
move |(buffer, utf8_buffer, tool_states), chunk| {
let result = match chunk {
Ok(bytes) => {
utf8_buffer.extend_from_slice(&bytes);
match String::from_utf8(utf8_buffer.clone()) {
Ok(text) => {
buffer.push_str(&text);
utf8_buffer.clear();
}
Err(e) => {
let valid_up_to = e.utf8_error().valid_up_to();
if valid_up_to > 0 {
let valid =
String::from_utf8_lossy(&utf8_buffer[..valid_up_to]);
buffer.push_str(&valid);
utf8_buffer.drain(..valid_up_to);
}
}
}
let mut results = Vec::new();
while let Some(pos) = buffer.find("\n\n") {
let event = buffer[..pos + 2].to_string();
buffer.drain(..pos + 2);
match parse_anthropic_sse_chunk_with_tools(&event, tool_states) {
Ok(Some(chunk)) => results.push(Ok(chunk)),
Ok(None) => {}
Err(e) => results.push(Err(e)),
}
}
Some(results)
}
Err(e) => Some(vec![Err(LLMError::HttpError(e.to_string()))]),
};
async move { result }
},
)
.flat_map(futures::stream::iter);
Box::pin(stream)
}
#[async_trait]
impl CompletionProvider for Anthropic {
async fn complete(
&self,
_req: &CompletionRequest,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<CompletionResponse, LLMError> {
Err(LLMError::ProviderError(
"Anthropic completion endpoint is not implemented; use chat APIs instead".to_string(),
))
}
}
#[async_trait]
impl EmbeddingProvider for Anthropic {
async fn embed(&self, _text: Vec<String>) -> Result<Vec<Vec<f32>>, LLMError> {
Err(LLMError::ProviderError(
"Embedding not supported".to_string(),
))
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct AnthropicModelListResponse {
data: Vec<AnthropicModelEntry>,
}
impl ModelListResponse for AnthropicModelListResponse {
fn get_models(&self) -> Vec<String> {
self.data.iter().map(|m| m.id.clone()).collect()
}
fn get_models_raw(&self) -> Vec<Box<dyn ModelListRawEntry>> {
self.data
.iter()
.map(|e| Box::new(e.clone()) as Box<dyn ModelListRawEntry>)
.collect()
}
fn get_backend(&self) -> String {
LLMBackend::Anthropic.to_string()
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct AnthropicModelEntry {
created_at: DateTime<Utc>,
id: String,
#[serde(flatten)]
extra: Value,
}
impl ModelListRawEntry for AnthropicModelEntry {
fn get_id(&self) -> String {
self.id.clone()
}
fn get_created_at(&self) -> DateTime<Utc> {
self.created_at
}
fn get_raw(&self) -> Value {
self.extra.clone()
}
}
#[async_trait]
impl ModelsProvider for Anthropic {
async fn list_models(
&self,
_request: Option<&ModelListRequest>,
) -> Result<Box<dyn ModelListResponse>, LLMError> {
let resp = self
.client
.get("https://api.anthropic.com/v1/models")
.header("x-api-key", &self.api_key)
.header("Content-Type", "application/json")
.header("anthropic-version", "2023-06-01")
.send()
.await?;
let resp = ensure_success(resp, "Anthropic").await?;
let result: AnthropicModelListResponse = resp.json().await?;
Ok(Box::new(result))
}
}
impl crate::LLMProvider for Anthropic {}
impl crate::HasConfig for Anthropic {
type Config = crate::NoConfig;
}
fn parse_anthropic_sse_chunk(chunk: &str) -> Result<Option<String>, LLMError> {
for line in chunk.lines() {
let line = line.trim();
if let Some(data) = line.strip_prefix("data: ") {
match serde_json::from_str::<AnthropicStreamResponse>(data) {
Ok(response) => {
if response.response_type == "content_block_delta"
&& let Some(delta) = response.delta
&& let Some(text) = delta.text
{
return Ok(Some(text));
}
return Ok(None);
}
Err(_) => continue,
}
}
}
Ok(None)
}
#[derive(Debug, Default)]
struct ToolUseState {
id: String,
name: String,
json_buffer: String,
}
fn parse_anthropic_sse_chunk_with_tools(
chunk: &str,
tool_states: &mut HashMap<usize, ToolUseState>,
) -> Result<Option<StreamChunk>, LLMError> {
for line in chunk.lines() {
let line = line.trim();
if let Some(data) = line.strip_prefix("data: ") {
match serde_json::from_str::<AnthropicStreamResponse>(data) {
Ok(response) => {
match response.response_type.as_str() {
"content_block_start" => {
if let (Some(index), Some(content_block)) =
(response.index, response.content_block)
&& content_block.block_type == "tool_use"
{
let id = content_block.id.unwrap_or_default();
let name = content_block.name.unwrap_or_default();
tool_states.insert(
index,
ToolUseState {
id: id.clone(),
name: name.clone(),
json_buffer: String::default(),
},
);
return Ok(Some(StreamChunk::ToolUseStart { index, id, name }));
}
}
"content_block_delta" => {
if let (Some(index), Some(delta)) = (response.index, response.delta) {
match delta.delta_type.as_deref() {
Some("text_delta") => {
if let Some(text) = delta.text {
return Ok(Some(StreamChunk::Text(text)));
}
}
Some("input_json_delta") => {
if let Some(partial_json) = delta.partial_json {
if let Some(state) = tool_states.get_mut(&index) {
state.json_buffer.push_str(&partial_json);
}
return Ok(Some(StreamChunk::ToolUseInputDelta {
index,
partial_json,
}));
}
}
_ => {}
}
}
}
"content_block_stop" => {
if let Some(index) = response.index {
if let Some(state) = tool_states.remove(&index) {
let arguments = if state.json_buffer.is_empty() {
"{}".to_string()
} else {
state.json_buffer
};
let tool_call = ToolCall {
id: state.id,
call_type: "function".to_string(),
function: FunctionCall {
name: state.name,
arguments,
},
};
return Ok(Some(StreamChunk::ToolUseComplete {
index,
tool_call,
}));
}
}
}
"message_delta" => {
if let Some(delta) = response.delta
&& let Some(stop_reason) = delta.stop_reason
{
return Ok(Some(StreamChunk::Done { stop_reason }));
}
}
_ => {}
}
return Ok(None);
}
Err(_) => continue,
}
}
}
Ok(None)
}
impl LLMBuilder<Anthropic> {
pub fn build(self) -> Result<Arc<Anthropic>, LLMError> {
let api_key = self.api_key.ok_or_else(|| {
LLMError::invalid_request("No API key provided for Anthropic".to_string())
})?;
let anthro = Anthropic::new(
api_key,
self.model,
self.max_tokens,
self.temperature,
self.timeout_seconds,
self.top_p,
self.top_k,
self.tool_choice,
self.reasoning,
self.reasoning_budget_tokens,
);
Ok(Arc::new(anthro))
}
}
#[cfg(test)]
mod tests {
use super::*;
const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
use crate::chat::{FunctionTool, ImageMime};
#[test]
fn test_parse_stream_text_delta() {
let chunk = r#"event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}
"#;
let mut tool_states = HashMap::new();
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::Text(text)) => assert_eq!(text, "Hello"),
_ => panic!("Expected Text chunk, got {:?}", result),
}
}
#[test]
fn test_parse_stream_tool_use_start() {
let chunk = r#"event: content_block_start
data: {"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "toolu_01ABC", "name": "get_weather", "input": {}}}
"#;
let mut tool_states = HashMap::new();
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::ToolUseStart { index, id, name }) => {
assert_eq!(index, 1);
assert_eq!(id, "toolu_01ABC");
assert_eq!(name, "get_weather");
}
_ => panic!("Expected ToolUseStart chunk, got {:?}", result),
}
assert!(tool_states.contains_key(&1));
assert_eq!(tool_states[&1].id, "toolu_01ABC");
assert_eq!(tool_states[&1].name, "get_weather");
}
#[test]
fn test_parse_stream_tool_use_input_delta() {
let chunk = r#"event: content_block_delta
data: {"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "{\"location\":"}}"#;
let mut tool_states = HashMap::default();
tool_states.insert(
1,
ToolUseState {
id: "toolu_01ABC".to_string(),
name: "get_weather".to_string(),
json_buffer: String::default(),
},
);
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::ToolUseInputDelta {
index,
partial_json,
}) => {
assert_eq!(index, 1);
assert_eq!(partial_json, "{\"location\":");
}
_ => panic!("Expected ToolUseInputDelta chunk, got {:?}", result),
}
assert_eq!(tool_states[&1].json_buffer, "{\"location\":");
}
#[test]
fn test_parse_stream_tool_use_complete() {
let chunk = r#"event: content_block_stop
data: {"type": "content_block_stop", "index": 1}
"#;
let mut tool_states = HashMap::new();
tool_states.insert(
1,
ToolUseState {
id: "toolu_01ABC".to_string(),
name: "get_weather".to_string(),
json_buffer: r#"{"location": "Paris"}"#.to_string(),
},
);
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::ToolUseComplete { index, tool_call }) => {
assert_eq!(index, 1);
assert_eq!(tool_call.id, "toolu_01ABC");
assert_eq!(tool_call.function.name, "get_weather");
assert_eq!(tool_call.function.arguments, r#"{"location": "Paris"}"#);
}
_ => panic!("Expected ToolUseComplete chunk, got {:?}", result),
}
assert!(!tool_states.contains_key(&1));
}
#[test]
fn test_parse_stream_tool_use_complete_empty_arguments() {
let chunk = r#"event: content_block_stop
data: {"type": "content_block_stop", "index": 1}
"#;
let mut tool_states = HashMap::default();
tool_states.insert(
1,
ToolUseState {
id: "toolu_01XYZ".to_string(),
name: "get_current_time".to_string(),
json_buffer: String::default(), },
);
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::ToolUseComplete { index, tool_call }) => {
assert_eq!(index, 1);
assert_eq!(tool_call.id, "toolu_01XYZ");
assert_eq!(tool_call.function.name, "get_current_time");
assert_eq!(
tool_call.function.arguments, "{}",
"Empty arguments should default to '{{}}' not empty string"
);
}
_ => panic!("Expected ToolUseComplete chunk, got {:?}", result),
}
assert!(!tool_states.contains_key(&1));
}
#[test]
fn test_parse_stream_done_tool_use() {
let chunk = r#"event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "tool_use"}}
"#;
let mut tool_states = HashMap::new();
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::Done { stop_reason }) => {
assert_eq!(stop_reason, "tool_use");
}
_ => panic!("Expected Done chunk, got {:?}", result),
}
}
#[test]
fn test_parse_stream_done_end_turn() {
let chunk = r#"event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}}
"#;
let mut tool_states = HashMap::new();
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::Done { stop_reason }) => {
assert_eq!(stop_reason, "end_turn");
}
_ => panic!("Expected Done chunk, got {:?}", result),
}
}
#[test]
fn test_parse_stream_full_tool_use_sequence() {
let mut tool_states = HashMap::new();
let start_chunk = r#"event: content_block_start
data: {"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "toolu_01ABC", "name": "get_weather", "input": {}}}
"#;
let result = parse_anthropic_sse_chunk_with_tools(start_chunk, &mut tool_states).unwrap();
assert!(matches!(result, Some(StreamChunk::ToolUseStart { .. })));
let delta1 = r#"event: content_block_delta
data: {"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "{\"loc"}}
"#;
let _ = parse_anthropic_sse_chunk_with_tools(delta1, &mut tool_states).unwrap();
let delta2 = r#"event: content_block_delta
data: {"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "ation\": \"Paris\"}"}}
"#;
let _ = parse_anthropic_sse_chunk_with_tools(delta2, &mut tool_states).unwrap();
assert_eq!(tool_states[&1].json_buffer, "{\"location\": \"Paris\"}");
let stop_chunk = r#"event: content_block_stop
data: {"type": "content_block_stop", "index": 1}
"#;
let result = parse_anthropic_sse_chunk_with_tools(stop_chunk, &mut tool_states).unwrap();
match result {
Some(StreamChunk::ToolUseComplete { tool_call, .. }) => {
assert_eq!(tool_call.function.arguments, "{\"location\": \"Paris\"}");
}
_ => panic!("Expected ToolUseComplete"),
}
let done_chunk = r#"event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "tool_use"}}
"#;
let result = parse_anthropic_sse_chunk_with_tools(done_chunk, &mut tool_states).unwrap();
assert!(matches!(
result,
Some(StreamChunk::Done {
stop_reason
}) if stop_reason == "tool_use"
));
}
#[test]
fn test_parse_stream_mixed_text_and_tool() {
let mut tool_states = HashMap::new();
let text_chunk = r#"event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "I'll check the weather"}}
"#;
let result = parse_anthropic_sse_chunk_with_tools(text_chunk, &mut tool_states).unwrap();
assert!(matches!(result, Some(StreamChunk::Text(t)) if t == "I'll check the weather"));
let tool_start = r#"event: content_block_start
data: {"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "toolu_01XYZ", "name": "weather", "input": {}}}
"#;
let result = parse_anthropic_sse_chunk_with_tools(tool_start, &mut tool_states).unwrap();
assert!(
matches!(result, Some(StreamChunk::ToolUseStart { name, .. }) if name == "weather")
);
}
#[test]
fn test_parse_stream_ignores_message_start() {
let chunk = r#"event: message_start
data: {"type": "message_start", "message": {"id": "msg_123", "type": "message", "role": "assistant"}}
"#;
let mut tool_states = HashMap::new();
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
assert!(result.is_none());
}
#[test]
fn test_parse_stream_ignores_ping() {
let chunk = r#"event: ping
data: {"type": "ping"}
"#;
let mut tool_states = HashMap::new();
let result = parse_anthropic_sse_chunk_with_tools(chunk, &mut tool_states).unwrap();
assert!(result.is_none());
}
#[test]
fn test_convert_messages_to_anthropic_tool_result() {
let msg = ChatMessage {
role: ChatRole::Assistant,
message_type: MessageType::ToolResult(vec![ToolCall {
id: "tool_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: "{\"q\":\"value\"}".to_string(),
},
}]),
content: "result".to_string(),
};
let messages = vec![msg];
let converted =
Anthropic::convert_messages_to_anthropic(&messages).expect("should convert");
assert_eq!(converted.len(), 1);
let content = &converted[0].content[0];
assert_eq!(content.message_type, Some("tool_result"));
assert_eq!(content.tool_result_id.as_deref(), Some("tool_1"));
assert_eq!(content.tool_output.as_deref(), Some("{\"q\":\"value\"}"));
}
#[test]
fn test_convert_messages_to_anthropic_tool_use_parses_input() {
let msg = ChatMessage {
role: ChatRole::Assistant,
message_type: MessageType::ToolUse(vec![ToolCall {
id: "tool_call".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: "{\"q\":\"value\"}".to_string(),
},
}]),
content: "call".to_string(),
};
let messages = [msg];
let converted =
Anthropic::convert_messages_to_anthropic(&messages).expect("should convert");
let content = &converted[0].content[0];
assert_eq!(content.message_type, Some("tool_use"));
assert_eq!(content.tool_use_id.as_deref(), Some("tool_call"));
assert_eq!(content.tool_name.as_deref(), Some("lookup"));
assert_eq!(content.tool_input, Some(serde_json::json!({"q": "value"})));
}
#[test]
fn test_prepare_tools_and_choice_auto() {
let tool = Tool {
tool_type: "function".to_string(),
function: FunctionTool {
name: "lookup".to_string(),
description: "desc".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {}
}),
},
};
let tools_list = [tool];
let (tools, choice) =
Anthropic::prepare_tools_and_choice(Some(&tools_list), &Some(ToolChoice::Auto));
assert!(tools.is_some());
assert_eq!(choice.unwrap().get("type"), Some(&"auto".to_string()));
}
#[test]
fn test_prepare_tools_and_choice_specific_tool() {
let tool = Tool {
tool_type: "function".to_string(),
function: FunctionTool {
name: "lookup".to_string(),
description: "desc".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": { "q": { "type": "string" } }
}),
},
};
let tools_list = [tool];
let (tools, choice) = Anthropic::prepare_tools_and_choice(
Some(&tools_list),
&Some(ToolChoice::Tool("lookup".to_string())),
);
assert!(tools.is_some());
let choice = choice.unwrap();
assert_eq!(choice.get("type"), Some(&"tool".to_string()));
assert_eq!(choice.get("name"), Some(&"lookup".to_string()));
}
#[test]
fn test_prepare_tools_and_choice_ignored_without_tools() {
let (tools, choice) = Anthropic::prepare_tools_and_choice(None, &Some(ToolChoice::Auto));
assert!(tools.is_none());
assert!(choice.is_none());
}
#[test]
fn test_convert_messages_to_anthropic_image_and_pdf() {
let image = ChatMessage {
role: ChatRole::User,
message_type: MessageType::Image((ImageMime::PNG, vec![1, 2, 3])),
content: "img".to_string(),
};
let pdf = ChatMessage {
role: ChatRole::User,
message_type: MessageType::Pdf(vec![9, 8, 7]),
content: "doc".to_string(),
};
let messages = [image, pdf];
let converted =
Anthropic::convert_messages_to_anthropic(&messages).expect("should convert");
assert_eq!(converted.len(), 2);
let image_content = &converted[0].content[0];
assert_eq!(image_content.message_type, Some("image"));
assert_eq!(
image_content.source.as_ref().expect("source").media_type,
"image/png"
);
let pdf_content = &converted[1].content[0];
assert_eq!(pdf_content.message_type, Some("document"));
assert_eq!(
pdf_content.source.as_ref().expect("source").media_type,
"application/pdf"
);
}
#[test]
fn test_anthropic_builder_requires_api_key() {
let err = LLMBuilder::<Anthropic>::new().build().unwrap_err();
assert!(err.to_string().contains("No API key provided"));
}
#[test]
fn test_complete_response_helpers_cover_text_thinking_tools_and_usage() {
let text_response = AnthropicCompleteResponse {
content: vec![
AnthropicContent {
text: Some("answer".to_string()),
content_type: Some("text".to_string()),
thinking: None,
name: None,
input: None,
id: None,
},
AnthropicContent {
text: Some("follow-up".to_string()),
content_type: None,
thinking: None,
name: None,
input: None,
id: None,
},
],
usage: Some(AnthropicUsage {
input_tokens: 3,
output_tokens: 5,
cache_creation_input_tokens: Some(2),
cache_read_input_tokens: Some(1),
}),
};
assert_eq!(text_response.text().as_deref(), Some("answer\nfollow-up"));
assert_eq!(text_response.thinking(), None);
assert_eq!(text_response.tool_calls(), None);
let usage = text_response.usage().expect("usage should be present");
assert_eq!(usage.total_tokens, 8);
assert_eq!(
usage
.prompt_tokens_details
.as_ref()
.and_then(|details| details.cached_tokens),
Some(3)
);
assert_eq!(text_response.to_string(), "answer\nfollow-up");
let thinking_response = AnthropicCompleteResponse {
content: vec![AnthropicContent {
text: None,
content_type: Some("thinking".to_string()),
thinking: Some("reasoning".to_string()),
name: None,
input: None,
id: None,
}],
usage: None,
};
assert_eq!(thinking_response.thinking().as_deref(), Some("reasoning"));
assert_eq!(thinking_response.to_string(), "reasoning");
let tool_response = AnthropicCompleteResponse {
content: vec![AnthropicContent {
text: None,
content_type: Some("tool_use".to_string()),
thinking: None,
name: Some("lookup".to_string()),
input: Some(serde_json::json!({"q": "value"})),
id: Some("tool_1".to_string()),
}],
usage: None,
};
let tool_calls = tool_response
.tool_calls()
.expect("tool calls should be present");
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id, "tool_1");
assert_eq!(tool_calls[0].function.name, "lookup");
assert_eq!(tool_calls[0].function.arguments, "{\"q\":\"value\"}");
assert!(tool_response.to_string().contains("\"name\": lookup"));
}
#[test]
fn test_convert_messages_to_anthropic_filters_system_and_covers_url_and_invalid_tool_json() {
let messages = vec![
ChatMessage {
role: ChatRole::System,
message_type: MessageType::Text,
content: "system".to_string(),
},
ChatMessage {
role: ChatRole::User,
message_type: MessageType::ImageURL("https://example.com/image.png".to_string()),
content: "remote image".to_string(),
},
ChatMessage {
role: ChatRole::Assistant,
message_type: MessageType::ToolUse(vec![ToolCall {
id: "tool_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: "not-json".to_string(),
},
}]),
content: "tool call".to_string(),
},
];
let converted =
Anthropic::convert_messages_to_anthropic(&messages).expect("should convert");
assert_eq!(converted.len(), 2, "system messages should be filtered");
assert_eq!(converted[0].role, "user");
assert_eq!(converted[0].content[0].message_type, Some("image_url"));
assert_eq!(
converted[0].content[0]
.image_url
.as_ref()
.expect("image url content should exist")
.url,
"https://example.com/image.png"
);
let tool_use = &converted[1].content[0];
assert_eq!(tool_use.message_type, Some("tool_use"));
assert_eq!(tool_use.tool_use_id.as_deref(), Some("tool_1"));
assert_eq!(tool_use.tool_name.as_deref(), Some("lookup"));
assert_eq!(
tool_use.tool_input,
Some(Value::String("not-json".to_string()))
);
}
#[tokio::test]
async fn test_list_models_maps_401_to_auth_error() {
let server = httpmock::MockServer::start();
let _mock = server.mock(|when, then| {
when.method(httpmock::Method::GET).path("/v1/models");
then.status(401)
.body(r#"{"error":{"message":"invalid key"}}"#);
});
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/v1/models", server.base_url()))
.header("x-api-key", "key")
.send()
.await
.expect("request should complete");
let err = ensure_success(resp, "Anthropic")
.await
.expect_err("401 should map to AuthError");
assert!(matches!(err, LLMError::AuthError { .. }));
}
#[test]
fn test_convert_messages_to_anthropic_rejects_system_only() {
let messages = [ChatMessage {
role: ChatRole::System,
message_type: MessageType::Text,
content: "system only".to_string(),
}];
let err = Anthropic::convert_messages_to_anthropic(&messages)
.expect_err("system-only input should be rejected");
assert!(matches!(
err,
LLMError::InvalidRequest { message, .. }
if message == "At least one non-system message is required"
));
}
#[test]
fn test_build_stream_request_accepts_pdf() {
let provider = Anthropic::new(
"key",
Some("claude-test".to_string()),
Some(64),
None,
None,
None,
None,
None,
None,
None,
);
let messages = [ChatMessage {
role: ChatRole::User,
message_type: MessageType::Pdf(vec![1, 2, 3]),
content: "doc".to_string(),
}];
let request = provider
.build_stream_request(&messages, None)
.expect("PDF should be accepted in Anthropic stream requests");
assert_eq!(request.messages.len(), 1);
let content = &request.messages[0].content[0];
assert_eq!(content.message_type, Some("document"));
let source = content.source.as_ref().expect("PDF should use source");
assert_eq!(source.media_type, "application/pdf");
}
#[test]
fn test_prepare_tools_and_choice_and_constructor_cover_remaining_variants() {
let tool = Tool {
tool_type: "function".to_string(),
function: FunctionTool {
name: "lookup".to_string(),
description: "desc".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {}
}),
},
};
let tools_list = [tool];
let (_tools, any_choice) =
Anthropic::prepare_tools_and_choice(Some(&tools_list), &Some(ToolChoice::Any));
assert_eq!(any_choice.unwrap().get("type"), Some(&"any".to_string()));
let (_tools, none_choice) =
Anthropic::prepare_tools_and_choice(Some(&tools_list), &Some(ToolChoice::None));
assert_eq!(none_choice.unwrap().get("type"), Some(&"none".to_string()));
let provider = Anthropic::new(
"key",
Some("claude-test".to_string()),
Some(64),
Some(0.2),
Some(7),
Some(0.9),
Some(5),
Some(ToolChoice::Any),
Some(true),
Some(2048),
);
assert_eq!(provider.api_key, "key");
assert_eq!(provider.model, "claude-test");
assert_eq!(provider.max_tokens, 64);
assert_eq!(provider.temperature, 0.2);
assert_eq!(provider.timeout_seconds, 7);
assert_eq!(provider.top_p, Some(0.9));
assert_eq!(provider.top_k, Some(5));
assert!(matches!(provider.tool_choice, Some(ToolChoice::Any)));
assert!(provider.reasoning);
assert_eq!(provider.thinking_budget_tokens, Some(2048));
}
#[tokio::test]
async fn test_chat_validation_rejects_missing_api_key_and_system_only_messages() {
let auth_err = Anthropic::new("", None, None, None, None, None, None, None, None, None)
.chat_with_tools(&[ChatMessage::user().content("hello").build()], None, None)
.await
.expect_err("missing api key should fail");
assert!(matches!(auth_err, LLMError::AuthError { .. }));
let invalid = Anthropic::new("key", None, None, None, None, None, None, None, None, None)
.chat_with_tools(
&[ChatMessage {
role: ChatRole::System,
message_type: MessageType::Text,
content: "system only".to_string(),
}],
None,
None,
)
.await
.expect_err("system-only messages should be rejected");
assert!(matches!(invalid, LLMError::InvalidRequest { .. }));
let stream_auth_err =
match Anthropic::new("", None, None, None, None, None, None, None, None, None)
.chat_stream(&[ChatMessage::user().content("hello").build()], None)
.await
{
Ok(_) => panic!("missing api key should fail for chat_stream"),
Err(err) => err,
};
assert!(matches!(stream_auth_err, LLMError::AuthError { .. }));
let stream_invalid =
match Anthropic::new("key", None, None, None, None, None, None, None, None, None)
.chat_stream(
&[ChatMessage {
role: ChatRole::System,
message_type: MessageType::Text,
content: "system only".to_string(),
}],
None,
)
.await
{
Ok(_) => panic!("system-only streaming messages should be rejected"),
Err(err) => err,
};
assert!(matches!(stream_invalid, LLMError::InvalidRequest { .. }));
}
#[tokio::test]
async fn test_complete_returns_provider_error() {
let provider = Anthropic::new(
"key",
Some("claude-test".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
);
let request = CompletionRequest {
prompt: "hello".to_string(),
max_tokens: None,
temperature: None,
};
let err = provider
.complete(&request, None)
.await
.expect_err("unsupported completion endpoint should return an error");
assert!(matches!(
err,
LLMError::ProviderError(message)
if message == "Anthropic completion endpoint is not implemented; use chat APIs instead"
));
}
#[test]
fn output_config_serialises_correctly_when_schema_present() {
let schema_value = serde_json::json!({
"type": "object",
"properties": {
"label": { "type": "string", "enum": ["Person", "Organisation", "Location"] }
},
"required": ["label"]
});
let output_config = Some(AnthropicOutputConfig {
format: AnthropicOutputFormat {
format_type: "json_schema",
schema: schema_value.clone(),
},
});
let req = AnthropicCompleteRequest {
messages: vec![],
model: "claude-test",
max_tokens: Some(128),
temperature: Some(0.0),
system: None,
stream: Some(false),
top_p: None,
top_k: None,
tools: None,
tool_choice: None,
thinking: None,
output_config,
};
let value = serde_json::to_value(&req).expect("serialisation must not fail");
assert_eq!(
value["output_config"]["format"]["type"],
serde_json::Value::String("json_schema".to_string()),
"output_config.format.type must be 'json_schema'"
);
assert_eq!(
value["output_config"]["format"]["schema"], schema_value,
"output_config.format.schema must round-trip without mutation",
);
}
#[test]
fn output_config_absent_when_no_schema() {
let req = AnthropicCompleteRequest {
messages: vec![],
model: "claude-test",
max_tokens: Some(128),
temperature: Some(0.0),
system: None,
stream: Some(false),
top_p: None,
top_k: None,
tools: None,
tool_choice: None,
thinking: None,
output_config: None,
};
let value = serde_json::to_value(&req).expect("serialisation must not fail");
assert!(
value.get("output_config").is_none(),
"output_config key must be absent when None (skip_serializing_if)"
);
}
#[test]
fn output_config_absent_when_schema_field_is_none() {
let sof = StructuredOutputFormat {
name: "test".to_string(),
description: None,
schema: None,
strict: None,
};
let output_config = build_output_config(Some(sof));
assert!(
output_config.is_none(),
"output_config must be None when StructuredOutputFormat.schema is None"
);
let req = AnthropicCompleteRequest {
messages: vec![],
model: "claude-test",
max_tokens: Some(128),
temperature: Some(0.0),
system: None,
stream: Some(false),
top_p: None,
top_k: None,
tools: None,
tool_choice: None,
thinking: None,
output_config,
};
let value = serde_json::to_value(&req).expect("serialisation must not fail");
assert!(
value.get("output_config").is_none(),
"output_config key must be absent in serialised JSON when schema field is None"
);
}
#[cfg(all(feature = "anthropic", not(target_arch = "wasm32")))]
#[tokio::test]
#[ignore]
async fn output_config_integration_real_api() {
let api_key = match std::env::var(ANTHROPIC_API_KEY_ENV) {
Ok(k) if !k.is_empty() => k,
_ => {
eprintln!("{ANTHROPIC_API_KEY_ENV} not set — skipping integration test");
return;
}
};
let schema = serde_json::json!({
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "neutral", "negative"]
}
},
"required": ["sentiment"],
"additionalProperties": false
});
let provider = Anthropic::new(
api_key,
Some("claude-haiku-4-5-20251001".to_string()),
Some(64),
Some(0.0),
Some(30),
None,
None,
None,
None,
None,
);
let messages = vec![ChatMessage {
role: ChatRole::User,
message_type: crate::chat::MessageType::Text,
content: "Classify the sentiment of: 'I love sunny days!'".to_string(),
}];
let sof = StructuredOutputFormat {
name: "sentiment_response".to_string(),
description: Some("Sentiment classification result".to_string()),
schema: Some(schema),
strict: Some(true),
};
let response = provider
.chat_with_tools(&messages, None, Some(sof))
.await
.expect("Real API call must succeed");
let text = response.text().expect("Response must contain text content");
let parsed: serde_json::Value =
serde_json::from_str(&text).expect("Response must be valid JSON per output_config");
let sentiment = parsed
.get("sentiment")
.and_then(|v| v.as_str())
.expect("Response JSON must contain 'sentiment' field");
assert!(
["positive", "neutral", "negative"].contains(&sentiment),
"sentiment must be one of the enum values, got: {sentiment}"
);
}
}