use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::{json, Value};
use tracing::{error, info, warn};
use zeroize::Zeroize;
use crate::providers::{multimodal, ProviderError};
use crate::traits::{
ChatOptions, ModelProvider, ProviderResponse, ResponseMode, TokenUsage, ToolCall,
ToolChoiceMode,
};
const DEFAULT_ANTHROPIC_MAX_TOKENS: u32 = 16_384;
pub struct AnthropicNativeProvider {
client: Client,
base_url: String,
api_key: String,
max_tokens: u32,
extra_headers: HashMap<String, String>,
}
fn normalize_tool_name(name: &str) -> String {
name.trim().to_string()
}
impl Drop for AnthropicNativeProvider {
fn drop(&mut self) {
self.api_key.zeroize();
}
}
impl AnthropicNativeProvider {
pub fn new_with_options(
api_key: &str,
base_url: Option<&str>,
max_tokens: Option<u32>,
extra_headers: Option<HashMap<String, String>>,
) -> Self {
let client = crate::providers::build_http_client(Duration::from_secs(120))
.unwrap_or_else(|e| panic!("failed to build HTTP client: {e}"));
let normalized_base_url = base_url
.unwrap_or("https://api.anthropic.com/v1")
.trim_end_matches('/')
.to_string();
Self {
client,
base_url: normalized_base_url,
api_key: api_key.to_string(),
max_tokens: max_tokens
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_ANTHROPIC_MAX_TOKENS),
extra_headers: extra_headers.unwrap_or_default(),
}
}
fn with_extra_headers(&self, mut request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
for (k, v) in &self.extra_headers {
request = request.header(k, v);
}
request
}
fn convert_messages(&self, messages: &[Value]) -> (Option<String>, Vec<Value>) {
let mut system_prompt: Option<String> = None;
let mut anthropic_msgs = Vec::new();
for msg in messages {
let role = msg["role"].as_str().unwrap_or("user");
match role {
"system" => {
let text = msg["content"].as_str().unwrap_or("").to_string();
if let Some(ref mut existing) = system_prompt {
existing.push_str("\n\n");
existing.push_str(&text);
} else {
system_prompt = Some(text);
}
}
"user" => {
let content = multimodal::openai_content_to_anthropic_blocks(&msg["content"]);
anthropic_msgs.push(json!({
"role": "user",
"content": content
}));
}
"assistant" => {
let mut content_blocks = Vec::new();
if let Some(text) = msg.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content_blocks.push(json!({
"type": "text",
"text": text
}));
}
}
if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) {
for tc in tool_calls {
let name = tc["function"]["name"].as_str().unwrap_or("");
let id = tc["id"].as_str().unwrap_or("");
let input: Value = serde_json::from_str(
tc["function"]["arguments"].as_str().unwrap_or("{}"),
)
.unwrap_or(json!({}));
content_blocks.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
if !content_blocks.is_empty() {
anthropic_msgs.push(json!({
"role": "assistant",
"content": content_blocks
}));
}
}
"tool" => {
let tool_use_id = msg["tool_call_id"].as_str().unwrap_or("");
let content_str = msg["content"].as_str().unwrap_or("");
anthropic_msgs.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content_str
}]
}));
}
_ => {}
}
}
let merged_msgs = self.merge_adjacent_roles(anthropic_msgs);
(system_prompt, merged_msgs)
}
fn merge_adjacent_roles(&self, msgs: Vec<Value>) -> Vec<Value> {
let mut result: Vec<Value> = Vec::new();
for msg in msgs {
if let Some(last) = result.last_mut() {
if last["role"] == msg["role"] {
Self::normalize_content_to_blocks(last);
let mut new_blocks = Self::msg_content_to_blocks(&msg);
last["content"]
.as_array_mut()
.unwrap()
.append(&mut new_blocks);
continue;
}
}
let mut new_msg = msg.clone();
Self::normalize_content_to_blocks(&mut new_msg);
result.push(new_msg);
}
result
}
fn normalize_content_to_blocks(msg: &mut Value) {
if let Some(content_str) = msg["content"].as_str() {
let text = content_str.to_string();
msg["content"] = json!([{ "type": "text", "text": text }]);
}
}
fn msg_content_to_blocks(msg: &Value) -> Vec<Value> {
if let Some(content_str) = msg["content"].as_str() {
vec![json!({ "type": "text", "text": content_str })]
} else if let Some(arr) = msg["content"].as_array() {
arr.clone()
} else {
vec![]
}
}
fn convert_tools(&self, tools: &[Value]) -> Option<Vec<Value>> {
if tools.is_empty() {
return None;
}
let mut anthropic_tools = Vec::new();
for tool in tools {
if let Some(func) = tool.get("function") {
anthropic_tools.push(json!({
"name": func["name"],
"description": func.get("description").unwrap_or(&json!("")),
"input_schema": func["parameters"]
}));
}
}
Some(anthropic_tools)
}
fn build_request_body(
&self,
model: &str,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> Value {
let (system, converted_msgs) = self.convert_messages(messages);
let effective_tools: &[Value] = if matches!(options.tool_choice, ToolChoiceMode::None) {
&[]
} else {
tools
};
let anthropic_tools = self.convert_tools(effective_tools);
let effective_max_tokens = options.max_tokens_override.unwrap_or(self.max_tokens);
let mut body = json!({
"model": model,
"max_tokens": effective_max_tokens,
"messages": converted_msgs,
});
if let Some(sys) = system {
body["system"] = json!(sys);
}
if let Some(at) = anthropic_tools {
body["tools"] = json!(at);
}
if !effective_tools.is_empty() {
match &options.tool_choice {
ToolChoiceMode::Auto | ToolChoiceMode::None => {}
ToolChoiceMode::Required => {
body["tool_choice"] = json!({ "type": "any" });
}
ToolChoiceMode::Specific(name) => {
body["tool_choice"] = json!({
"type": "tool",
"name": name
});
}
}
} else if matches!(
options.tool_choice,
ToolChoiceMode::Required | ToolChoiceMode::Specific(_)
) {
warn!(
tool_choice = ?options.tool_choice,
"Ignoring required/specific tool_choice because no tools were provided"
);
}
body
}
fn parse_usage(data: &Value, model: &str) -> Option<TokenUsage> {
data.get("usage").and_then(|u| {
Some(TokenUsage {
input_tokens: u.get("input_tokens")?.as_u64()? as u32,
output_tokens: u.get("output_tokens")?.as_u64()? as u32,
cached_input_tokens: u
.get("cache_read_input_tokens")
.and_then(Value::as_u64)
.map(|tokens| tokens.min(u32::MAX as u64) as u32),
cache_creation_input_tokens: u
.get("cache_creation_input_tokens")
.and_then(Value::as_u64)
.map(|tokens| tokens.min(u32::MAX as u64) as u32),
model: model.to_string(),
})
})
}
}
#[async_trait]
impl ModelProvider for AnthropicNativeProvider {
async fn chat(
&self,
model: &str,
messages: &[Value],
tools: &[Value],
) -> anyhow::Result<ProviderResponse> {
self.chat_with_options(model, messages, tools, &ChatOptions::default())
.await
}
async fn chat_with_options(
&self,
model: &str,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<ProviderResponse> {
let body = self.build_request_body(model, messages, tools, options);
if !matches!(options.response_mode, ResponseMode::Text) {
warn!(
response_mode = ?options.response_mode,
"Anthropic native provider does not enforce response_mode; relying on prompt contract"
);
}
info!(
model,
url = %self.base_url,
response_mode = ?options.response_mode,
tool_choice = ?options.tool_choice,
"Calling Anthropic Native"
);
let request = self
.with_extra_headers(
self.client
.post(format!("{}/messages", self.base_url))
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json"),
)
.json(&body);
const LLM_CALL_HARD_TIMEOUT: Duration = Duration::from_secs(360);
let (status_code, text) = match tokio::time::timeout(LLM_CALL_HARD_TIMEOUT, async {
let resp = request.send().await.map_err(|e| {
error!("Anthropic HTTP request failed: {}", e);
anyhow::Error::from(ProviderError::network(&e))
})?;
let status = resp.status();
let text = resp.text().await.map_err(|e| {
error!("Failed to read response body: {}", e);
anyhow::Error::from(ProviderError::network(&e))
})?;
Ok::<(u16, String), anyhow::Error>((status.as_u16(), text))
})
.await
{
Ok(Ok(result)) => result,
Ok(Err(e)) => return Err(e),
Err(_elapsed) => {
error!(
timeout_secs = LLM_CALL_HARD_TIMEOUT.as_secs(),
"Anthropic API call exceeded hard timeout"
);
return Err(ProviderError::timeout_msg(
"Anthropic API call timed out (hard wall-clock limit)",
)
.into());
}
};
let status = reqwest::StatusCode::from_u16(status_code).unwrap_or(reqwest::StatusCode::OK);
if !status.is_success() {
error!(status = %status, "Anthropic API error: {}", text);
return Err(ProviderError::from_status(status.as_u16(), &text).into());
}
let data: Value = serde_json::from_str(&text).map_err(|e| {
error!("Failed to parse Anthropic response JSON: {}", e);
ProviderError::malformed_parse(format!(
"Malformed response from LLM provider (JSON parse error: {})",
e
))
})?;
let mut final_text = String::new();
let mut tool_calls = Vec::new();
if let Some(content_arr) = data["content"].as_array() {
for block in content_arr {
let btype = block["type"].as_str().unwrap_or("");
if btype == "text" {
if let Some(t) = block["text"].as_str() {
final_text.push_str(t);
}
} else if btype == "tool_use" {
let name = normalize_tool_name(block["name"].as_str().unwrap_or(""));
let id = block["id"].as_str().unwrap_or("").to_string();
let input = &block["input"];
let args = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
tool_calls.push(ToolCall {
id,
name,
arguments: args,
extra_content: None,
});
}
}
}
let usage = Self::parse_usage(&data, model);
Ok(ProviderResponse {
content: if final_text.is_empty() {
None
} else {
Some(final_text)
},
tool_calls,
usage,
thinking: None,
response_note: None,
})
}
async fn list_models(&self) -> anyhow::Result<Vec<String>> {
let known_models = vec![
"claude-sonnet-4-5-20250514".to_string(),
"claude-3-5-sonnet-20241022".to_string(),
"claude-3-5-haiku-20241022".to_string(),
"claude-3-opus-20240229".to_string(),
"claude-3-haiku-20240307".to_string(),
];
let url = format!("{}/models", self.base_url);
let resp = match self
.with_extra_headers(
self.client
.get(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01"),
)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!("Failed to fetch Anthropic model list, using known models: {e}");
return Ok(known_models);
}
};
if !resp.status().is_success() {
warn!(
"Anthropic /models returned {}, using known models",
resp.status()
);
return Ok(known_models);
}
let data: Value = resp.json().await?;
let models: Vec<String> = data["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("id").and_then(Value::as_str).map(str::to_string))
.collect()
})
.unwrap_or_default();
if models.is_empty() {
return Ok(known_models);
}
Ok(models)
}
}
#[cfg(test)]
impl AnthropicNativeProvider {
pub fn new(api_key: &str) -> Self {
Self::new_with_options(api_key, None, None, None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn provider() -> AnthropicNativeProvider {
AnthropicNativeProvider::new("test-key")
}
#[test]
fn test_system_message_extracted() {
let p = provider();
let messages = vec![
json!({"role": "system", "content": "You are helpful."}),
json!({"role": "user", "content": "Hello"}),
];
let (system, msgs) = p.convert_messages(&messages);
assert_eq!(system, Some("You are helpful.".to_string()));
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "user");
}
#[test]
fn test_multiple_system_messages_merged() {
let p = provider();
let messages = vec![
json!({"role": "system", "content": "You are helpful."}),
json!({"role": "system", "content": "Be concise."}),
json!({"role": "user", "content": "Hello"}),
];
let (system, _msgs) = p.convert_messages(&messages);
let system_text = system.unwrap();
assert!(
system_text.contains("You are helpful."),
"System prompt should contain first system message"
);
assert!(
system_text.contains("Be concise."),
"System prompt should contain second system message"
);
assert!(
system_text.contains("\n\n"),
"System messages should be joined with double newline"
);
assert_eq!(system_text, "You are helpful.\n\nBe concise.");
}
#[test]
fn test_assistant_tool_calls_converted() {
let p = provider();
let messages = vec![
json!({"role": "user", "content": "What time is it?"}),
json!({
"role": "assistant",
"content": "Let me check.",
"tool_calls": [{
"id": "call_123",
"function": {
"name": "get_time",
"arguments": "{\"timezone\": \"UTC\"}"
}
}]
}),
];
let (_system, msgs) = p.convert_messages(&messages);
assert_eq!(msgs.len(), 2);
let assistant_msg = &msgs[1];
assert_eq!(assistant_msg["role"], "assistant");
let content = assistant_msg["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "Let me check.");
assert_eq!(content[1]["type"], "tool_use");
assert_eq!(content[1]["id"], "call_123");
assert_eq!(content[1]["name"], "get_time");
assert_eq!(content[1]["input"]["timezone"], "UTC");
}
#[test]
fn test_parse_usage_extracts_cache_read_and_creation_tokens() {
let payload = json!({
"usage": {
"input_tokens": 100,
"output_tokens": 5,
"cache_read_input_tokens": 70,
"cache_creation_input_tokens": 12
}
});
let usage =
AnthropicNativeProvider::parse_usage(&payload, "claude-sonnet-4-5").expect("usage");
assert_eq!(usage.cached_input_tokens, Some(70));
assert_eq!(usage.cache_creation_input_tokens, Some(12));
assert_eq!(usage.fresh_input_tokens(), Some(30));
}
#[test]
fn test_tool_result_as_user_message() {
let p = provider();
let messages = vec![
json!({"role": "user", "content": "Hello"}),
json!({
"role": "assistant",
"content": "Calling tool.",
"tool_calls": [{
"id": "call_abc",
"function": {
"name": "my_tool",
"arguments": "{}"
}
}]
}),
json!({
"role": "tool",
"tool_call_id": "call_abc",
"content": "tool output here"
}),
];
let (_system, msgs) = p.convert_messages(&messages);
assert_eq!(msgs.len(), 3);
let tool_msg = &msgs[2];
assert_eq!(tool_msg["role"], "user");
let content = tool_msg["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_abc");
assert_eq!(content[0]["content"], "tool output here");
}
#[test]
fn test_consecutive_same_role_merged() {
let p = provider();
let messages = vec![
json!({"role": "user", "content": "Do two things"}),
json!({
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"function": {"name": "tool_a", "arguments": "{}"}
},
{
"id": "call_2",
"function": {"name": "tool_b", "arguments": "{}"}
}
]
}),
json!({
"role": "tool",
"tool_call_id": "call_1",
"content": "result A"
}),
json!({
"role": "tool",
"tool_call_id": "call_2",
"content": "result B"
}),
];
let (_system, msgs) = p.convert_messages(&messages);
assert_eq!(
msgs.len(),
3,
"Two tool results should be merged into one user message"
);
let merged_user = &msgs[2];
assert_eq!(merged_user["role"], "user");
let content = merged_user["content"].as_array().unwrap();
assert_eq!(
content.len(),
2,
"Merged message should have 2 content blocks"
);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_1");
assert_eq!(content[1]["type"], "tool_result");
assert_eq!(content[1]["tool_use_id"], "call_2");
}
#[test]
fn test_empty_content_skipped() {
let p = provider();
let messages = vec![
json!({"role": "user", "content": "Hello"}),
json!({
"role": "assistant",
"content": "",
}),
];
let (_system, msgs) = p.convert_messages(&messages);
assert_eq!(
msgs.len(),
1,
"Empty assistant message should be skipped, got {} messages",
msgs.len()
);
assert_eq!(msgs[0]["role"], "user");
}
fn openai_tool(name: &str) -> Value {
json!({
"type": "function",
"function": {
"name": name,
"description": "test tool",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string" }
}
}
}
})
}
#[test]
fn test_build_request_body_required_tool_choice_sets_any() {
let p = provider();
let messages = vec![json!({"role": "user", "content": "run a tool"})];
let tools = vec![openai_tool("search_files")];
let options = ChatOptions {
response_mode: ResponseMode::Text,
tool_choice: ToolChoiceMode::Required,
..ChatOptions::default()
};
let body = p.build_request_body("claude-3-5-sonnet-20241022", &messages, &tools, &options);
assert_eq!(body["tool_choice"]["type"], "any");
assert!(body.get("tools").is_some(), "tools should be present");
}
#[test]
fn test_build_request_body_specific_tool_choice_sets_named_tool() {
let p = provider();
let messages = vec![json!({"role": "user", "content": "run search_files"})];
let tools = vec![openai_tool("search_files")];
let options = ChatOptions {
response_mode: ResponseMode::Text,
tool_choice: ToolChoiceMode::Specific("search_files".to_string()),
..ChatOptions::default()
};
let body = p.build_request_body("claude-3-5-sonnet-20241022", &messages, &tools, &options);
assert_eq!(body["tool_choice"]["type"], "tool");
assert_eq!(body["tool_choice"]["name"], "search_files");
}
#[test]
fn test_build_request_body_none_tool_choice_strips_tools() {
let p = provider();
let messages = vec![json!({"role": "user", "content": "just answer text"})];
let tools = vec![openai_tool("search_files")];
let options = ChatOptions {
response_mode: ResponseMode::Text,
tool_choice: ToolChoiceMode::None,
..ChatOptions::default()
};
let body = p.build_request_body("claude-3-5-sonnet-20241022", &messages, &tools, &options);
assert!(body.get("tools").is_none(), "tools should be stripped");
assert!(
body.get("tool_choice").is_none(),
"tool_choice should be omitted when tools are stripped"
);
}
#[test]
fn test_build_request_body_none_tool_choice_with_empty_tools_is_safe() {
let p = provider();
let messages = vec![json!({"role": "user", "content": "classify intent"})];
let tools: Vec<Value> = vec![];
let options = ChatOptions {
response_mode: ResponseMode::Text,
tool_choice: ToolChoiceMode::None,
..ChatOptions::default()
};
let body = p.build_request_body("claude-3-5-sonnet-20241022", &messages, &tools, &options);
assert!(body.get("tools").is_none(), "tools should be omitted");
assert!(
body.get("tool_choice").is_none(),
"tool_choice should stay omitted when no tools are provided"
);
}
#[test]
fn test_build_request_body_uses_provider_max_tokens_default() {
let p = provider();
let messages = vec![json!({"role": "user", "content": "hello"})];
let body = p.build_request_body(
"claude-3-5-sonnet-20241022",
&messages,
&[],
&ChatOptions::default(),
);
assert_eq!(body["max_tokens"], DEFAULT_ANTHROPIC_MAX_TOKENS);
}
#[test]
fn test_build_request_body_uses_configured_max_tokens() {
let p = AnthropicNativeProvider::new_with_options("test-key", None, Some(32768), None);
let messages = vec![json!({"role": "user", "content": "hello"})];
let body = p.build_request_body(
"claude-3-5-sonnet-20241022",
&messages,
&[],
&ChatOptions::default(),
);
assert_eq!(body["max_tokens"], 32768);
}
#[test]
fn test_new_with_options_applies_base_url_and_headers() {
let p = AnthropicNativeProvider::new_with_options(
"test-key",
Some("https://example.com/v1/"),
Some(2048),
Some(HashMap::from([("x-test".to_string(), "1".to_string())])),
);
assert_eq!(p.base_url, "https://example.com/v1");
assert_eq!(p.max_tokens, 2048);
assert_eq!(p.extra_headers.get("x-test"), Some(&"1".to_string()));
}
#[test]
fn test_normalize_tool_name_trims_whitespace() {
assert_eq!(normalize_tool_name(" terminal "), "terminal");
}
}