use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use super::{LlmResponse, LlmService, Message, ToolCall, ToolSchema};
const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
pub struct AnthropicLlm {
client: reqwest::Client,
api_key: String,
model: String,
max_tokens: u32,
}
impl AnthropicLlm {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: super::http_client(DEFAULT_TIMEOUT),
api_key: api_key.into(),
model: model.into(),
max_tokens: 1024,
}
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.client = super::http_client(timeout);
self
}
}
#[derive(Deserialize)]
struct ApiResponse {
content: Vec<ContentBlock>,
}
#[derive(Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
text: String,
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default)]
input: Option<Value>,
}
fn build_payload(messages: &[Message]) -> (String, Vec<Value>) {
let mut system = String::new();
let mut out: Vec<Value> = Vec::new();
let mut i = 0;
while i < messages.len() {
let m = &messages[i];
match m.role.as_str() {
"system" => {
if !system.is_empty() {
system.push('\n');
}
system.push_str(&m.content);
i += 1;
}
"tool" => {
let mut blocks = Vec::new();
while i < messages.len() && messages[i].role == "tool" {
let t = &messages[i];
blocks.push(json!({
"type": "tool_result",
"tool_use_id": t.tool_call_id.clone().unwrap_or_default(),
"content": t.content,
}));
i += 1;
}
out.push(json!({ "role": "user", "content": blocks }));
}
"assistant" if !m.tool_calls.is_empty() => {
let mut blocks = Vec::new();
if !m.content.is_empty() {
blocks.push(json!({ "type": "text", "text": m.content }));
}
for tc in &m.tool_calls {
blocks.push(json!({
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": tc.args,
}));
}
out.push(json!({ "role": "assistant", "content": blocks }));
i += 1;
}
role => {
out.push(json!({ "role": role, "content": m.content }));
i += 1;
}
}
}
(system, out)
}
fn build_tools(tools: &[ToolSchema]) -> Vec<Value> {
tools
.iter()
.map(|t| json!({
"name": t.name,
"description": t.description,
"input_schema": t.parameters,
}))
.collect()
}
#[async_trait]
impl LlmService for AnthropicLlm {
async fn submit_prompt(&self, messages: Vec<Message>) -> Result<String> {
let (system, turns) = build_payload(&messages);
let body = json!({
"model": self.model,
"max_tokens": self.max_tokens,
"system": system,
"messages": turns,
});
let data = self.call(body).await?;
Ok(text_of(&data.content))
}
async fn chat(&self, messages: Vec<Message>, tools: &[ToolSchema]) -> Result<LlmResponse> {
let (system, turns) = build_payload(&messages);
let mut body = json!({
"model": self.model,
"max_tokens": self.max_tokens,
"system": system,
"messages": turns,
});
if !tools.is_empty() {
body["tools"] = json!(build_tools(tools));
}
let data = self.call(body).await?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for block in data.content {
match block.kind.as_str() {
"text" => text.push_str(&block.text),
"tool_use" => tool_calls.push(ToolCall {
id: block.id,
name: block.name,
args: block.input.unwrap_or_else(|| json!({})),
}),
_ => {}
}
}
Ok(LlmResponse {
text: if text.is_empty() { None } else { Some(text) },
tool_calls,
})
}
}
impl AnthropicLlm {
async fn call(&self, body: Value) -> Result<ApiResponse> {
let response = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.context("failed to call Anthropic")?
.error_for_status()
.context("Anthropic returned an error status")?;
response.json().await.context("failed to parse Anthropic response")
}
}
fn text_of(content: &[ContentBlock]) -> String {
content
.iter()
.filter(|b| b.kind == "text")
.map(|b| b.text.clone())
.collect::<Vec<_>>()
.join("")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn consecutive_tool_messages_are_merged_into_one_user_message() {
let messages = vec![
Message::system("you are a bot"),
Message::user("do stuff"),
Message::assistant_tool_calls(
"",
vec![
ToolCall { id: "a".into(), name: "t1".into(), args: json!({"x":1}) },
ToolCall { id: "b".into(), name: "t2".into(), args: json!({"y":2}) },
],
),
Message::tool_result("a", "result A"),
Message::tool_result("b", "result B"),
];
let (system, turns) = build_payload(&messages);
assert_eq!(system, "you are a bot");
assert_eq!(turns.len(), 3);
let asst = &turns[1];
assert_eq!(asst["role"], "assistant");
assert_eq!(asst["content"].as_array().unwrap().len(), 2);
assert_eq!(asst["content"][0]["type"], "tool_use");
let merged = &turns[2];
assert_eq!(merged["role"], "user");
let blocks = merged["content"].as_array().unwrap();
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0]["type"], "tool_result");
assert_eq!(blocks[0]["tool_use_id"], "a");
assert_eq!(blocks[1]["tool_use_id"], "b");
}
#[test]
fn plain_messages_stay_plain() {
let messages = vec![Message::user("hi"), Message::assistant("hello")];
let (_system, turns) = build_payload(&messages);
assert_eq!(turns.len(), 2);
assert_eq!(turns[0], json!({ "role": "user", "content": "hi" }));
assert_eq!(turns[1], json!({ "role": "assistant", "content": "hello" }));
}
}