use super::{
FunctionDefinition, LlmProvider, LlmResponse, Message, Role, ToolCall, ToolDefinition, Usage,
};
use anyhow::{bail, Result};
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::Client;
use reqwest_eventsource::{Event, RequestBuilderExt};
use serde::Deserialize;
use serde_json::{json, Value};
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
pub const ANTHROPIC_API_BASE: &str = "anthropic";
const ANTHROPIC_VERSION: &str = "2023-06-01";
pub struct AnthropicProvider {
api_key: String,
pub model: String,
client: Client,
pub stream_print: AtomicBool,
}
impl AnthropicProvider {
pub fn new(api_key: String, model: String) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(300))
.build()
.expect("Failed to create HTTP client");
AnthropicProvider {
api_key,
model,
client,
stream_print: AtomicBool::new(true),
}
}
pub fn set_stream_print(&self, enabled: bool) {
self.stream_print.store(enabled, Ordering::Relaxed);
}
fn build_request_body(&self, messages: &[Message], tools: &[ToolDefinition]) -> Value {
let mut system_prompt: Option<String> = None;
let mut conversation: Vec<Value> = Vec::new();
for msg in messages {
match msg.role {
Role::System => {
let text = msg.text_content().unwrap_or_default();
match system_prompt.as_mut() {
Some(existing) => {
existing.push('\n');
existing.push_str(&text);
}
None => system_prompt = Some(text),
}
}
Role::Tool => {
let tool_call_id = msg.tool_call_id.as_deref().unwrap_or("unknown");
let result_text = msg.text_content().unwrap_or_default();
conversation.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_call_id,
"content": result_text,
}]
}));
}
Role::User | Role::Assistant => {
let content = build_anthropic_content(&msg.content, msg.tool_calls.as_deref());
let role_str = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
_ => unreachable!(),
};
conversation.push(json!({
"role": role_str,
"content": content,
}));
}
}
}
let anthropic_tools: Vec<Value> = tools
.iter()
.map(|t| build_anthropic_tool(&t.function))
.collect();
let mut body = json!({
"model": self.model,
"max_tokens": 8096,
"messages": conversation,
"stream": true,
});
if let Some(sys) = system_prompt {
body["system"] = json!(sys);
}
if !anthropic_tools.is_empty() {
body["tools"] = json!(anthropic_tools);
}
body
}
async fn try_once(
&self,
messages: &[Message],
tools: &[ToolDefinition],
) -> Result<LlmResponse> {
let body = self.build_request_body(messages, tools);
let request = self
.client
.post(ANTHROPIC_API_URL)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json")
.json(&body);
let mut es = match request.eventsource() {
Ok(es) => es,
Err(e) => bail!("Failed to start Anthropic SSE stream: {}", e),
};
let mut text_content = String::new();
let mut tool_use_blocks: std::collections::HashMap<u32, AnthropicToolUseBuilder> =
std::collections::HashMap::new();
let mut input_tokens: u32 = 0;
let mut output_tokens: u32 = 0;
while let Some(event) = es.next().await {
match event {
Ok(Event::Open) => {}
Ok(Event::Message(msg)) => {
let event_type = msg.event.as_str();
match event_type {
"message_start" => {
if let Ok(v) = serde_json::from_str::<Value>(&msg.data) {
if let Some(n) = v
.pointer("/message/usage/input_tokens")
.and_then(|v| v.as_u64())
{
input_tokens = n as u32;
}
}
}
"content_block_start" => {
if let Ok(data) =
serde_json::from_str::<ContentBlockStartData>(&msg.data)
{
if data.content_block.block_type == "tool_use" {
tool_use_blocks.insert(
data.index,
AnthropicToolUseBuilder {
id: data.content_block.id.unwrap_or_default(),
name: data.content_block.name.unwrap_or_default(),
input_json: String::new(),
},
);
}
}
}
"content_block_delta" => {
if let Ok(data) =
serde_json::from_str::<ContentBlockDeltaData>(&msg.data)
{
match data.delta.delta_type.as_str() {
"text_delta" => {
if let Some(text) = data.delta.text {
if self.stream_print.load(Ordering::Relaxed) {
print!("{}", text);
std::io::stdout().flush().ok();
}
text_content.push_str(&text);
}
}
"input_json_delta" => {
if let Some(partial) = data.delta.partial_json {
if let Some(builder) =
tool_use_blocks.get_mut(&data.index)
{
builder.input_json.push_str(&partial);
}
}
}
_ => {} }
}
}
"content_block_stop" => {}
"message_delta" => {
if let Ok(data) = serde_json::from_str::<MessageDeltaData>(&msg.data) {
if let Some(n) = data.usage.and_then(|u| u.output_tokens) {
output_tokens = n;
}
}
}
"message_stop" => {
es.close();
break;
}
_ => {}
}
}
Err(e) => {
use reqwest_eventsource::Error as EsError;
match e {
EsError::InvalidStatusCode(status, _resp) => {
let code = status.as_u16();
bail!("Anthropic API error {}: {}", code, status);
}
EsError::Transport(e) => {
bail!("Anthropic transport error: {}", e);
}
other => {
bail!("Anthropic SSE error: {}", other);
}
}
}
}
}
let tool_calls: Vec<ToolCall> = tool_use_blocks.into_values().map(|b| b.build()).collect();
Ok(LlmResponse {
content: if text_content.is_empty() {
None
} else {
Some(text_content)
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
usage: Some(Usage {
prompt_tokens: input_tokens,
completion_tokens: output_tokens,
total_tokens: input_tokens + output_tokens,
}),
})
}
}
#[async_trait]
impl LlmProvider for AnthropicProvider {
async fn chat_completion(
&self,
messages: &[Message],
tools: &[ToolDefinition],
) -> Result<LlmResponse> {
self.try_once(messages, tools).await
}
fn set_stream_print(&self, enabled: bool) {
AnthropicProvider::set_stream_print(self, enabled);
}
}
fn build_anthropic_content(
parts: &[super::ContentPart],
tool_calls: Option<&[ToolCall]>,
) -> Vec<Value> {
let mut blocks: Vec<Value> = Vec::new();
for part in parts {
match part {
super::ContentPart::Text { text } => {
blocks.push(json!({
"type": "text",
"text": text,
}));
}
super::ContentPart::ImageUrl { image_url } => {
if image_url.url.starts_with("data:") {
if let Some((header, data)) = image_url.url.split_once(',') {
let mime = header
.strip_prefix("data:")
.and_then(|s| s.strip_suffix(";base64"))
.unwrap_or("image/jpeg");
blocks.push(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mime,
"data": data,
}
}));
}
} else {
blocks.push(json!({
"type": "image",
"source": {
"type": "url",
"url": image_url.url,
}
}));
}
}
super::ContentPart::ToolUse { id, name, input } => {
blocks.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input,
}));
}
super::ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
blocks.push(json!({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content,
"is_error": is_error,
}));
}
}
}
if let Some(tcs) = tool_calls {
for tc in tcs {
let input: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(json!({}));
blocks.push(json!({
"type": "tool_use",
"id": tc.id,
"name": tc.function.name,
"input": input,
}));
}
}
if blocks.is_empty() {
blocks.push(json!({"type": "text", "text": ""}));
}
blocks
}
fn build_anthropic_tool(func: &FunctionDefinition) -> Value {
json!({
"name": func.name,
"description": func.description,
"input_schema": func.parameters,
})
}
struct AnthropicToolUseBuilder {
id: String,
name: String,
input_json: String,
}
impl AnthropicToolUseBuilder {
fn build(self) -> ToolCall {
ToolCall {
id: self.id,
call_type: "function".to_string(),
function: super::FunctionCall {
name: self.name,
arguments: if self.input_json.is_empty() {
"{}".to_string()
} else {
self.input_json
},
},
}
}
}
#[derive(Deserialize)]
struct ContentBlockStartData {
index: u32,
content_block: ContentBlockStart,
}
#[derive(Deserialize)]
struct ContentBlockStart {
#[serde(rename = "type")]
block_type: String,
id: Option<String>,
name: Option<String>,
}
#[derive(Deserialize)]
struct ContentBlockDeltaData {
index: u32,
delta: ContentBlockDelta,
}
#[derive(Deserialize)]
struct ContentBlockDelta {
#[serde(rename = "type")]
delta_type: String,
text: Option<String>,
partial_json: Option<String>,
}
#[derive(Deserialize)]
struct MessageDeltaData {
usage: Option<MessageDeltaUsage>,
}
#[derive(Deserialize)]
struct MessageDeltaUsage {
output_tokens: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{ContentPart, FunctionDefinition, ImageUrl, Message, ToolDefinition};
use serde_json::json;
#[test]
fn test_system_message_extracted() {
let provider = AnthropicProvider::new("key".into(), "claude-opus-4-5".into());
let messages = vec![
Message::system("You are a helpful assistant."),
Message::user("Hello!"),
];
let body = provider.build_request_body(&messages, &[]);
assert_eq!(
body["system"], "You are a helpful assistant.",
"System prompt should be a top-level field"
);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.len(),
1,
"Only non-system messages should be in messages array"
);
assert_eq!(msgs[0]["role"], "user");
}
#[test]
fn test_user_message_content_block() {
let provider = AnthropicProvider::new("key".into(), "claude-opus-4-5".into());
let messages = vec![Message::user("Hello!")];
let body = provider.build_request_body(&messages, &[]);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs[0]["role"], "user");
let content = msgs[0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "Hello!");
}
#[test]
fn test_tool_result_message_conversion() {
let provider = AnthropicProvider::new("key".into(), "claude-opus-4-5".into());
let tool_msg = Message::tool("call_123", "File written successfully.");
let messages = vec![tool_msg];
let body = provider.build_request_body(&messages, &[]);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs[0]["role"], "user");
let content = msgs[0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_123");
assert_eq!(content[0]["content"], "File written successfully.");
}
#[test]
fn test_tool_definition_anthropic_format() {
let provider = AnthropicProvider::new("key".into(), "claude-opus-4-5".into());
let tools = vec![ToolDefinition {
def_type: "function".to_string(),
function: FunctionDefinition {
name: "file_write".to_string(),
description: "Write a file".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string" }
}
}),
},
}];
let body = provider.build_request_body(&[], &tools);
let tools_json = body["tools"].as_array().unwrap();
assert_eq!(tools_json[0]["name"], "file_write");
assert_eq!(tools_json[0]["description"], "Write a file");
assert!(
tools_json[0].get("input_schema").is_some(),
"Should use input_schema, not parameters"
);
assert!(
tools_json[0].get("parameters").is_none(),
"Should NOT have parameters field"
);
}
#[test]
fn test_no_tools_omitted_from_body() {
let provider = AnthropicProvider::new("key".into(), "claude-opus-4-5".into());
let messages = vec![Message::user("Hello!")];
let body = provider.build_request_body(&messages, &[]);
assert!(
body.get("tools").is_none(),
"tools field should be absent when there are no tools"
);
}
#[test]
fn test_no_system_message_omitted_from_body() {
let provider = AnthropicProvider::new("key".into(), "claude-opus-4-5".into());
let messages = vec![Message::user("Hello!")];
let body = provider.build_request_body(&messages, &[]);
assert!(
body.get("system").is_none(),
"system field should be absent when there is no system message"
);
}
#[test]
fn test_build_anthropic_content_text() {
let parts = vec![ContentPart::text("Hello!")];
let blocks = build_anthropic_content(&parts, None);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "Hello!");
}
#[test]
fn test_build_anthropic_content_tool_use() {
let parts = vec![ContentPart::ToolUse {
id: "toolu_01".to_string(),
name: "file_write".to_string(),
input: json!({ "path": "test.txt" }),
}];
let blocks = build_anthropic_content(&parts, None);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "tool_use");
assert_eq!(blocks[0]["id"], "toolu_01");
assert_eq!(blocks[0]["name"], "file_write");
}
#[test]
fn test_build_anthropic_content_tool_result() {
let parts = vec![ContentPart::ToolResult {
tool_use_id: "toolu_01".to_string(),
content: "done".to_string(),
is_error: false,
}];
let blocks = build_anthropic_content(&parts, None);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "tool_result");
assert_eq!(blocks[0]["tool_use_id"], "toolu_01");
assert_eq!(blocks[0]["content"], "done");
assert_eq!(blocks[0]["is_error"], false);
}
#[test]
fn test_build_anthropic_content_image_base64() {
let parts = vec![ContentPart::ImageUrl {
image_url: ImageUrl {
url: "data:image/png;base64,abc123".to_string(),
detail: None,
},
}];
let blocks = build_anthropic_content(&parts, None);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "image");
assert_eq!(blocks[0]["source"]["type"], "base64");
assert_eq!(blocks[0]["source"]["media_type"], "image/png");
assert_eq!(blocks[0]["source"]["data"], "abc123");
}
#[test]
fn test_build_anthropic_content_image_url() {
let parts = vec![ContentPart::ImageUrl {
image_url: ImageUrl {
url: "https://example.com/image.jpg".to_string(),
detail: None,
},
}];
let blocks = build_anthropic_content(&parts, None);
assert_eq!(blocks[0]["source"]["type"], "url");
assert_eq!(blocks[0]["source"]["url"], "https://example.com/image.jpg");
}
#[test]
fn test_empty_content_produces_empty_text_block() {
let blocks = build_anthropic_content(&[], None);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "");
}
#[test]
fn test_build_anthropic_tool_format() {
let func = FunctionDefinition {
name: "bash".to_string(),
description: "Run a bash command".to_string(),
parameters: json!({
"type": "object",
"properties": {
"command": { "type": "string" }
}
}),
};
let tool = build_anthropic_tool(&func);
assert_eq!(tool["name"], "bash");
assert_eq!(tool["description"], "Run a bash command");
assert_eq!(tool["input_schema"]["type"], "object");
assert!(tool.get("parameters").is_none());
}
#[test]
fn test_tool_use_builder() {
let builder = AnthropicToolUseBuilder {
id: "toolu_01".to_string(),
name: "file_write".to_string(),
input_json: r#"{"path":"test.txt","content":"hello"}"#.to_string(),
};
let tc = builder.build();
assert_eq!(tc.id, "toolu_01");
assert_eq!(tc.call_type, "function");
assert_eq!(tc.function.name, "file_write");
assert_eq!(
tc.function.arguments,
r#"{"path":"test.txt","content":"hello"}"#
);
}
#[test]
fn test_tool_use_builder_empty_input() {
let builder = AnthropicToolUseBuilder {
id: "toolu_02".to_string(),
name: "list_files".to_string(),
input_json: String::new(),
};
let tc = builder.build();
assert_eq!(tc.function.arguments, "{}");
}
}