use std::time::Duration;
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use super::{
ChatProvider, Completion, RetryPolicy, StreamChunk, StreamSender, TokenUsage, ToolCall,
ToolSpec,
};
const TOOL_SEARCH_BETA_HEADER: &str = "advanced-tool-use-2025-11-20";
use crate::error::{Error, Result};
use crate::message::{Message, Role};
#[derive(Debug, Clone)]
pub struct AnthropicProvider {
base_url: String,
api_key: String,
model: String,
client: Client,
temperature: f64,
max_tokens: u32,
retry: RetryPolicy,
#[allow(
dead_code,
reason = "API parity placeholder; used once Anthropic adds deferred search"
)]
max_search_rounds: usize,
}
impl AnthropicProvider {
pub fn new(
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(180))
.build()
.map_err(|e| Error::Config {
message: format!("failed to build HTTP client: {e}"),
})?;
Ok(Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
api_key: api_key.into(),
model: model.into(),
client,
temperature: 0.2,
max_tokens: crate::llm::DEFAULT_MAX_TOKENS,
retry: RetryPolicy::default(),
max_search_rounds: 3,
})
}
fn make_err(&self, ctx: impl Into<String>) -> Error {
Error::Llm {
provider: self.model.clone(),
message: ctx.into(),
}
}
pub fn with_temperature(mut self, t: f64) -> Self {
self.temperature = t;
self
}
pub fn with_max_tokens(mut self, n: u32) -> Self {
self.max_tokens = n;
self
}
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
pub fn with_max_search_rounds(mut self, n: usize) -> Self {
self.max_search_rounds = n;
self
}
async fn post_with_retry(&self, url: &str, body: &Value) -> Result<String> {
let mut attempt = 0;
loop {
tracing::debug!(target: "recursive::llm", request = %body, "POST {}", url);
let result = self
.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", TOOL_SEARCH_BETA_HEADER)
.header("content-type", "application/json")
.json(body)
.send()
.await;
match result {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
return resp.text().await.map_err(Error::from);
}
let text = resp.text().await?;
if let Some(backoff) =
self.retry
.backoff_for(attempt, Some(status.as_u16()), false)
{
tracing::warn!(
target: "recursive::llm",
attempt,
backoff_ms = backoff.as_millis(),
status = status.as_u16(),
"transient HTTP error, retrying"
);
tokio::time::sleep(backoff).await;
attempt += 1;
continue;
}
return Err(self.make_err(format!("HTTP {}: {}", status, text)));
}
Err(e) => {
if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
tracing::warn!(
target: "recursive::llm",
attempt,
backoff_ms = backoff.as_millis(),
error = %e,
"network error, retrying"
);
tokio::time::sleep(backoff).await;
attempt += 1;
continue;
}
return Err(self.make_err(format!("request failed: {e}")));
}
}
}
}
}
#[async_trait]
impl ChatProvider for AnthropicProvider {
fn supports_deferred_tools(&self) -> bool {
if std::env::var("RECURSIVE_DEFERRED_TOOLS")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
{
return true;
}
self.base_url.contains("api.anthropic.com")
}
async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion> {
let (system, messages) = extract_system_message(messages);
let messages = filter_leading_assistant(&messages);
let body = build_request(
&self.model,
self.temperature,
self.max_tokens,
system.as_deref(),
&messages,
tools,
);
let url = format!("{}/v1/messages", self.base_url);
let text = self.post_with_retry(&url, &body).await?;
let parsed: AnthropicResponse = serde_json::from_str(&text)
.map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
Ok(parse_completion(parsed))
}
async fn stream(
&self,
messages: &[Message],
tools: &[ToolSpec],
stream_tx: Option<StreamSender>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<Completion> {
self.stream_inner(messages, tools, stream_tx, cancel_token)
.await
}
}
impl AnthropicProvider {
async fn stream_inner(
&self,
messages: &[Message],
tools: &[ToolSpec],
stream_tx: Option<StreamSender>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<Completion> {
let (system, messages) = extract_system_message(messages);
let messages = filter_leading_assistant(&messages);
let mut body = build_request(
&self.model,
self.temperature,
self.max_tokens,
system.as_deref(),
&messages,
tools,
);
body["stream"] = Value::Bool(true);
let url = format!("{}/v1/messages", self.base_url);
let mut attempt = 0;
loop {
tracing::debug!(target: "recursive::llm", request = %body, "POST {} (stream)", url);
let result = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", TOOL_SEARCH_BETA_HEADER)
.header("content-type", "application/json")
.json(&body)
.send()
.await;
match result {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
return self
.parse_sse_stream(resp, stream_tx.clone(), cancel_token.clone())
.await;
}
let text = resp.text().await?;
tracing::debug!(target: "recursive::llm", body = %text, "error response (stream)");
if let Some(backoff) =
self.retry
.backoff_for(attempt, Some(status.as_u16()), false)
{
tracing::warn!(
target: "recursive::llm",
attempt,
backoff_ms = backoff.as_millis(),
status = status.as_u16(),
"transient HTTP error, retrying (stream)"
);
tokio::time::sleep(backoff).await;
attempt += 1;
continue;
}
return Err(self.make_err(format!("HTTP {}: {}", status, text)));
}
Err(e) => {
if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
tracing::warn!(
target: "recursive::llm",
attempt,
backoff_ms = backoff.as_millis(),
error = %e,
"network error, retrying (stream)"
);
tokio::time::sleep(backoff).await;
attempt += 1;
continue;
}
return Err(self.make_err(format!("request failed: {e}")));
}
}
}
}
async fn parse_sse_stream(
&self,
resp: reqwest::Response,
stream_tx: Option<StreamSender>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<Completion> {
let mut acc = SseAccum::default();
let mut byte_stream = resp.bytes_stream();
let mut incomplete: Vec<u8> = Vec::new();
let mut line_buf = String::new();
loop {
let chunk_result = if let Some(ct) = cancel_token.as_ref() {
tokio::select! {
biased;
_ = ct.cancelled() => {
let have_partial = !acc.content.is_empty()
|| !acc.reasoning_content.is_empty()
|| !acc.tool_calls.is_empty();
if have_partial {
tracing::warn!(
target: "recursive::llm",
content_len = acc.content.len(),
"SSE stream cancelled; returning partial completion"
);
acc.tool_calls.clear();
return Ok(Self::build_completion(
acc,
Some("interrupted".to_string()),
));
}
return Err(Error::Cancelled);
}
next = byte_stream.next() => next,
}
} else {
byte_stream.next().await
};
let bytes = match chunk_result {
Some(Ok(c)) => c,
Some(Err(e)) => {
let have_partial = !acc.content.is_empty()
|| !acc.reasoning_content.is_empty()
|| !acc.tool_calls.is_empty();
if have_partial {
tracing::warn!(
target: "recursive::llm",
error = %e,
content_len = acc.content.len(),
"SSE stream interrupted; returning partial completion"
);
acc.tool_calls.clear();
return Ok(Self::build_completion(acc, Some("interrupted".to_string())));
}
return Err(self.make_err(format!("SSE stream read error: {e}")));
}
None => break,
};
let combined: Vec<u8> = if incomplete.is_empty() {
bytes.to_vec()
} else {
let mut v = std::mem::take(&mut incomplete);
v.extend_from_slice(&bytes);
v
};
let valid_up_to = match std::str::from_utf8(&combined) {
Ok(_) => combined.len(),
Err(e) => e.valid_up_to(),
};
incomplete = combined[valid_up_to..].to_vec();
let text = unsafe { std::str::from_utf8_unchecked(&combined[..valid_up_to]) };
for ch in text.chars() {
if ch == '\n' {
let line = std::mem::take(&mut line_buf);
self.process_sse_line(&line, &mut acc, &stream_tx)?;
} else if ch != '\r' {
line_buf.push(ch);
}
}
}
Self::warn_incomplete_utf8_tail(&incomplete);
if !line_buf.is_empty() {
self.process_sse_line(&line_buf, &mut acc, &stream_tx)?;
}
let finish_reason = std::mem::take(&mut acc.finish_reason);
Ok(Self::build_completion(acc, finish_reason))
}
#[cfg_attr(test, mutants::skip)]
fn warn_incomplete_utf8_tail(incomplete: &[u8]) {
if !incomplete.is_empty() {
tracing::warn!(
target: "recursive::llm",
bytes = incomplete.len(),
"SSE stream ended with incomplete UTF-8 sequence; discarding tail bytes"
);
}
}
fn build_completion(acc: SseAccum, finish_reason: Option<String>) -> Completion {
let usage = if acc.input_tokens.is_some() || acc.output_tokens.is_some() {
let prompt = acc.input_tokens.unwrap_or(0);
let completion = acc.output_tokens.unwrap_or(0);
Some(TokenUsage {
reasoning_tokens: 0,
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt.saturating_add(completion),
cache_hit_tokens: acc.cache_read.unwrap_or(0),
cache_miss_tokens: prompt.saturating_add(acc.cache_creation.unwrap_or(0)),
})
} else {
None
};
let final_tool_calls: Vec<ToolCall> = acc
.tool_calls
.into_iter()
.filter(|tc| !tc.id.is_empty())
.map(|tc| {
let args: Value = if tc.partial_json.trim().is_empty() {
Value::Object(Default::default())
} else {
serde_json::from_str(&tc.partial_json)
.unwrap_or_else(|_| Value::String(tc.partial_json.clone()))
};
ToolCall {
id: tc.id,
name: tc.name,
arguments: args,
}
})
.collect();
Completion {
content: acc.content,
tool_calls: final_tool_calls,
finish_reason: finish_reason.map(|r| match r.as_str() {
"end_turn" => "stop".to_string(),
"max_tokens" => "length".to_string(),
"tool_use" => "tool_calls".to_string(),
other => other.to_string(),
}),
usage,
reasoning_content: if acc.reasoning_content.trim().is_empty() {
None
} else {
Some(acc.reasoning_content)
},
}
}
fn process_sse_line(
&self,
line: &str,
acc: &mut SseAccum,
stream_tx: &Option<StreamSender>,
) -> Result<()> {
if let Some(event_name) = line.strip_prefix("event: ") {
acc.current_event = Some(event_name.to_string());
return Ok(());
}
let Some(data) = line.strip_prefix("data: ") else {
return Ok(());
};
let event_type = acc.current_event.as_deref().unwrap_or("unknown");
match event_type {
"message_start" => {
let parsed: Value = serde_json::from_str(data).map_err(|e| {
self.make_err(format!(
"SSE parse error (message_start): {e}; data: {data}"
))
})?;
if let Some(msg) = parsed.get("message") {
if let Some(u) = msg.get("usage") {
acc.input_tokens = u
.get("input_tokens")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
acc.cache_creation = u
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
acc.cache_read = u
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
}
}
}
"content_block_start" => {
let parsed: Value = serde_json::from_str(data).map_err(|e| {
self.make_err(format!(
"SSE parse error (content_block_start): {e}; data: {data}"
))
})?;
if let Some(block) = parsed.get("content_block") {
let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
let index = parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
match block_type {
"tool_use" => {
let id = block
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = block
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
while acc.tool_calls.len() <= index {
acc.tool_calls.push(StreamToolCall::default());
}
acc.tool_calls[index].id = id;
acc.tool_calls[index].name = name;
}
"text" => {
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
acc.content.push_str(text);
if let Some(tx) = stream_tx {
let _ = tx.send(StreamChunk::Text(text.to_string()));
}
}
}
}
"thinking" => {
if let Some(t) = block.get("thinking").and_then(|v| v.as_str()) {
if !t.is_empty() {
acc.reasoning_content.push_str(t);
if let Some(tx) = stream_tx {
let _ = tx.send(StreamChunk::Reasoning(t.to_string()));
}
}
}
}
_ => {}
}
}
}
"content_block_delta" => {
let parsed: Value = serde_json::from_str(data).map_err(|e| {
self.make_err(format!(
"SSE parse error (content_block_delta): {e}; data: {data}"
))
})?;
if let Some(delta) = parsed.get("delta") {
let delta_type = delta.get("type").and_then(|v| v.as_str()).unwrap_or("");
match delta_type {
"text_delta" => Self::handle_text_delta(acc, delta, stream_tx),
"input_json_delta" => {
Self::handle_input_json_delta(acc, delta, &parsed);
}
"thinking_delta" => Self::handle_thinking_delta(acc, delta, stream_tx),
_ => {}
}
}
}
"message_delta" => {
let parsed: Value = serde_json::from_str(data).map_err(|e| {
self.make_err(format!(
"SSE parse error (message_delta): {e}; data: {data}"
))
})?;
if let Some(delta) = parsed.get("delta") {
if let Some(reason) = delta.get("stop_reason").and_then(|v| v.as_str()) {
acc.finish_reason = Some(reason.to_string());
}
}
if let Some(u) = parsed.get("usage") {
acc.output_tokens = u
.get("output_tokens")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
if let Some(v) = u.get("input_tokens").and_then(|v| v.as_u64()) {
acc.input_tokens = Some(v as u32);
}
if let Some(v) = u.get("cache_read_input_tokens").and_then(|v| v.as_u64()) {
acc.cache_read = Some(v as u32);
}
if let Some(v) = u
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
{
acc.cache_creation = Some(v as u32);
}
}
}
_ => {
tracing::debug!(target: "recursive::llm", event = %event_type, data = %data, "unhandled SSE event");
}
}
acc.current_event = None;
Ok(())
}
fn handle_text_delta(acc: &mut SseAccum, delta: &Value, stream_tx: &Option<StreamSender>) {
if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
acc.content.push_str(text);
if let Some(tx) = stream_tx {
let _ = tx.send(StreamChunk::Text(text.to_string()));
}
}
}
}
fn handle_input_json_delta(acc: &mut SseAccum, delta: &Value, parsed: &Value) {
if let Some(partial) = delta.get("partial_json").and_then(|v| v.as_str()) {
let index = parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
while acc.tool_calls.len() <= index {
acc.tool_calls.push(StreamToolCall::default());
}
acc.tool_calls[index].partial_json.push_str(partial);
}
}
fn handle_thinking_delta(acc: &mut SseAccum, delta: &Value, stream_tx: &Option<StreamSender>) {
if let Some(t) = delta.get("thinking").and_then(|v| v.as_str()) {
if !t.is_empty() {
acc.reasoning_content.push_str(t);
if let Some(tx) = stream_tx {
let _ = tx.send(StreamChunk::Reasoning(t.to_string()));
}
}
}
}
}
#[derive(Default)]
struct SseAccum {
content: String,
reasoning_content: String,
tool_calls: Vec<StreamToolCall>,
finish_reason: Option<String>,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
cache_creation: Option<u32>,
cache_read: Option<u32>,
current_event: Option<String>,
}
fn extract_system_message(messages: &[Message]) -> (Option<String>, Vec<Message>) {
if messages.first().is_some_and(|m| m.role == Role::System) {
let system = messages[0].content.clone();
let rest = messages[1..].to_vec();
(Some(system), rest)
} else {
(None, messages.to_vec())
}
}
fn filter_leading_assistant(messages: &[Message]) -> Vec<Message> {
let mut result = messages.to_vec();
while result
.first()
.is_some_and(|m| m.role == Role::Assistant && m.tool_calls.is_empty())
{
result.remove(0);
}
result
}
fn build_request(
model: &str,
temperature: f64,
max_tokens: u32,
system: Option<&str>,
messages: &[Message],
tools: &[ToolSpec],
) -> Value {
let mut req = serde_json::json!({
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
});
if let Some(sys) = system {
req["system"] = Value::String(sys.to_string());
}
let msgs: Vec<Value> = serialize_messages_anthropic(messages);
req["messages"] = Value::Array(msgs);
if !tools.is_empty() {
let tools_json: Vec<Value> = tools
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"description": t.description,
"input_schema": t.parameters,
})
})
.collect();
req["tools"] = Value::Array(tools_json);
}
req
}
fn serialize_message(m: &Message) -> Value {
let role = match m.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "user", };
if !m.tool_calls.is_empty() {
let tool_uses: Vec<Value> = m
.tool_calls
.iter()
.map(|c| {
serde_json::json!({
"type": "tool_use",
"id": c.id,
"name": c.name,
"input": c.arguments,
})
})
.collect();
serde_json::json!({
"role": role,
"content": tool_uses,
})
} else if let Some(id) = &m.tool_call_id {
serde_json::json!({
"role": role,
"content": [{
"type": "tool_result",
"tool_use_id": id,
"content": m.content,
}],
})
} else {
serde_json::json!({
"role": role,
"content": m.content,
})
}
}
fn serialize_messages_anthropic(messages: &[Message]) -> Vec<Value> {
let mut out: Vec<Value> = Vec::with_capacity(messages.len());
let mut i = 0;
while i < messages.len() {
let m = &messages[i];
if m.tool_call_id.is_some() || m.role == Role::Tool {
let mut blocks: Vec<Value> = Vec::new();
while i < messages.len() {
let tm = &messages[i];
if tm.tool_call_id.is_none() && tm.role != Role::Tool {
break;
}
if let Some(id) = &tm.tool_call_id {
let content_value = if tm.content.starts_with('[') {
if let Ok(names) = serde_json::from_str::<Vec<String>>(&tm.content) {
Value::Array(
names
.iter()
.map(|n| {
serde_json::json!({
"type": "tool_reference",
"tool_name": n,
})
})
.collect(),
)
} else {
Value::String(tm.content.clone())
}
} else {
Value::String(tm.content.clone())
};
blocks.push(serde_json::json!({
"type": "tool_result",
"tool_use_id": id,
"content": content_value,
}));
}
i += 1;
}
if !blocks.is_empty() {
out.push(serde_json::json!({
"role": "user",
"content": blocks,
}));
}
} else {
out.push(serialize_message(m));
i += 1;
}
}
out
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
#[serde(default)]
content: Vec<ContentBlock>,
#[serde(default)]
usage: Option<AnthropicUsage>,
#[serde(default)]
stop_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum ContentBlock {
#[serde(rename = "text")]
Text {
#[serde(default)]
text: String,
},
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
#[serde(default)]
input: Value,
},
#[serde(rename = "thinking")]
Thinking {
#[serde(default)]
thinking: String,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize)]
struct AnthropicUsage {
#[serde(default)]
input_tokens: Option<u32>,
#[serde(default)]
output_tokens: Option<u32>,
#[serde(default)]
cache_creation_input_tokens: Option<u32>,
#[serde(default)]
cache_read_input_tokens: Option<u32>,
}
impl AnthropicUsage {
fn to_token_usage(&self) -> TokenUsage {
let prompt = self.input_tokens.unwrap_or(0);
TokenUsage {
reasoning_tokens: 0,
prompt_tokens: prompt,
completion_tokens: self.output_tokens.unwrap_or(0),
total_tokens: prompt.saturating_add(self.output_tokens.unwrap_or(0)),
cache_hit_tokens: self.cache_read_input_tokens.unwrap_or(0),
cache_miss_tokens: prompt.saturating_add(self.cache_creation_input_tokens.unwrap_or(0)),
}
}
}
#[derive(Default)]
struct StreamToolCall {
id: String,
name: String,
partial_json: String,
}
fn parse_completion(response: AnthropicResponse) -> Completion {
let mut content = String::new();
let mut reasoning_content = String::new();
let mut tool_calls = Vec::new();
for block in response.content {
match block {
ContentBlock::Text { text } => {
content.push_str(&text);
}
ContentBlock::Thinking { thinking } => {
reasoning_content.push_str(&thinking);
}
ContentBlock::ToolUse { id, name, input } => {
tool_calls.push(ToolCall {
id,
name,
arguments: input,
});
}
ContentBlock::Unknown => {}
}
}
let finish_reason = response.stop_reason.map(|r| match r.as_str() {
"end_turn" => "stop".to_string(),
"max_tokens" => "length".to_string(),
"tool_use" => "tool_calls".to_string(),
other => other.to_string(),
});
Completion {
content,
tool_calls,
finish_reason,
usage: response.usage.map(|u| u.to_token_usage()),
reasoning_content: if reasoning_content.trim().is_empty() {
None
} else {
Some(reasoning_content)
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::TOOL_SEARCH_TOOL_NAME;
use serde_json::json;
#[test]
fn extract_system_message_when_present() {
let msgs = vec![
Message::system("You are helpful.".to_string()),
Message::user("Hello".to_string()),
];
let (system, rest) = extract_system_message(&msgs);
assert_eq!(system, Some("You are helpful.".to_string()));
assert_eq!(rest.len(), 1);
assert_eq!(rest[0].role, Role::User);
}
#[test]
fn extract_system_message_when_not_present() {
let msgs = vec![Message::user("Hello".to_string())];
let (system, rest) = extract_system_message(&msgs);
assert_eq!(system, None);
assert_eq!(rest.len(), 1);
}
#[test]
fn filter_leading_assistant_removes_empty_assistant() {
let msgs = vec![
Message::assistant("Hi".to_string()),
Message::user("Hello".to_string()),
];
let filtered = filter_leading_assistant(&msgs);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].role, Role::User);
}
#[test]
fn filter_leading_assistant_keeps_assistant_with_tool_calls() {
let msgs = vec![
Message::assistant_with_tool_calls(
"I'll use a tool".to_string(),
vec![ToolCall {
id: "call_1".to_string(),
name: "test".to_string(),
arguments: Value::Object(Default::default()),
}],
),
Message::user("Hello".to_string()),
];
let filtered = filter_leading_assistant(&msgs);
assert_eq!(filtered.len(), 2);
}
#[test]
fn parses_text_response() {
let raw = r#"{
"type": "message",
"content": [{"type": "text", "text": "Hello"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
}"#;
let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
let c = parse_completion(parsed);
assert_eq!(c.content, "Hello");
assert!(c.tool_calls.is_empty());
assert_eq!(c.finish_reason, Some("stop".to_string()));
assert!(c.usage.is_some());
}
#[test]
fn parses_tool_use_response() {
let raw = r#"{
"type": "message",
"content": [
{"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "src/lib.rs"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 100, "output_tokens": 50}
}"#;
let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
let c = parse_completion(parsed);
assert!(c.content.is_empty());
assert_eq!(c.tool_calls.len(), 1);
assert_eq!(c.tool_calls[0].id, "call_1");
assert_eq!(c.tool_calls[0].name, "Read");
assert_eq!(c.tool_calls[0].arguments["path"], "src/lib.rs");
}
#[test]
fn parses_mixed_content_response() {
let raw = r#"{
"type": "message",
"content": [
{"type": "text", "text": "I'll read that file. "},
{"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "src/lib.rs"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 50, "output_tokens": 25}
}"#;
let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
let c = parse_completion(parsed);
assert_eq!(c.content, "I'll read that file. ");
assert_eq!(c.tool_calls.len(), 1);
}
#[test]
fn parses_thinking_block_into_reasoning_content() {
let raw = r#"{
"type": "message",
"content": [
{"type": "thinking", "thinking": "Let me reason about this.", "signature": "abc"},
{"type": "text", "text": "The answer is 42."}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
}"#;
let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
let c = parse_completion(parsed);
assert_eq!(c.content, "The answer is 42.");
assert_eq!(
c.reasoning_content.as_deref(),
Some("Let me reason about this.")
);
}
#[test]
fn parses_usage_correctly() {
let raw = r#"{
"type": "message",
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 20,
"cache_read_input_tokens": 30
}
}"#;
let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
let c = parse_completion(parsed);
let u = c.usage.unwrap();
assert_eq!(u.prompt_tokens, 100);
assert_eq!(u.completion_tokens, 50);
assert_eq!(u.cache_hit_tokens, 30);
assert_eq!(u.cache_miss_tokens, 120);
assert_eq!(
u.cache_hit_tokens + u.cache_miss_tokens,
100 + 30 + 20,
"hit + miss must equal total input tokens"
);
}
#[test]
fn serializes_tool_result_message() {
let msg = Message::tool_result("call_123".to_string(), "result content".to_string());
let v = serialize_message(&msg);
assert_eq!(v["role"], "user");
let content = &v["content"][0];
assert_eq!(content["type"], "tool_result");
assert_eq!(content["tool_use_id"], "call_123");
assert_eq!(content["content"], "result content");
}
#[test]
fn serializes_assistant_with_tool_calls() {
let msg = Message::assistant_with_tool_calls(
"I'll use a tool".to_string(),
vec![ToolCall {
id: "abc".to_string(),
name: "Write".to_string(),
arguments: serde_json::json!({"path": "a", "contents": "b"}),
}],
);
let v = serialize_message(&msg);
assert_eq!(v["role"], "assistant");
let content = &v["content"][0];
assert_eq!(content["type"], "tool_use");
assert_eq!(content["name"], "Write");
}
#[test]
fn builds_request_with_system() {
let req = build_request(
"claude-3",
0.2,
4096,
Some("You are helpful."),
&[Message::user("Hi".to_string())],
&[],
);
assert_eq!(req["system"], "You are helpful.");
assert_eq!(req["model"], "claude-3");
}
#[test]
fn builds_request_without_system() {
let req = build_request(
"claude-3",
0.2,
4096,
None,
&[Message::user("Hi".to_string())],
&[],
);
assert!(req.get("system").is_none());
}
#[test]
fn builds_request_with_tools() {
let tools = vec![ToolSpec {
name: "test".to_string(),
description: "A test tool".to_string(),
parameters: serde_json::json!({"type": "object"}),
}];
let req = build_request(
"claude-3",
0.2,
4096,
None,
&[Message::user("Hi".to_string())],
&tools,
);
assert!(req.get("tools").is_some());
}
#[tokio::test]
async fn test_a_mock_server_returns_canned_response() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"type":"message","content":[{"type":"text","text":"Hello from Anthropic"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
write!(stream, "{}", response).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let result = provider
.complete(&[Message::user("hi".to_string())], &[])
.await;
let _ = handle.join();
let completion = result.expect("should succeed");
assert!(completion.content.contains("Hello from Anthropic"));
assert!(completion.usage.is_some());
let u = completion.usage.unwrap();
assert_eq!(u.prompt_tokens, 10);
assert_eq!(u.completion_tokens, 5);
}
#[tokio::test]
async fn test_b_error_response_includes_model_name() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"error":{"type":"authentication_error","message":"Invalid API Key"}}"#;
let response = format!(
"HTTP/1.1 401 Unauthorized\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
write!(stream, "{}", response).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-invalid", "claude-3-opus")
.unwrap();
let err = provider
.complete(&[Message::user("hi".to_string())], &[])
.await
.unwrap_err();
let _ = handle.join();
let msg = err.to_string();
assert!(
msg.contains("claude-3-opus"),
"error should contain model name: {msg}"
);
assert!(
msg.contains("401"),
"error should contain HTTP status: {msg}"
);
}
#[tokio::test]
async fn test_c_tool_use_extracts_correct_tool_call() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"type":"message","content":[{"type":"tool_use","id":"call_abc123","name":"Read","input":{"path":"src/lib.rs"}}],"stop_reason":"tool_use","usage":{"input_tokens":50,"output_tokens":30}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
write!(stream, "{}", response).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let result = provider
.complete(&[Message::user("read the file".to_string())], &[])
.await;
let _ = handle.join();
let completion = result.expect("should succeed");
assert_eq!(completion.tool_calls.len(), 1);
let tc = &completion.tool_calls[0];
assert_eq!(tc.id, "call_abc123");
assert_eq!(tc.name, "Read");
assert_eq!(tc.arguments["path"], "src/lib.rs");
}
#[tokio::test]
async fn test_d_stream_request_includes_stream_true() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let captured_clone = captured.clone();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).unwrap();
let request = String::from_utf8_lossy(&buf[..n]).to_string();
if let Some(body_start) = request.find("\r\n\r\n") {
let body = request[body_start + 4..].trim().to_string();
*captured_clone.lock().unwrap() = body.clone();
}
let body = "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let _ = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await;
let body_str = captured.lock().unwrap().clone();
let body: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(body["stream"], true);
}
#[tokio::test]
async fn test_e_stream_text_deltas_accumulate() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" \"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"World\"}}
event: message_delta
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
event: message_stop
data: {\"type\":\"message_stop\"}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await
.unwrap();
assert_eq!(completion.content, "Hello World");
assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
assert!(completion.usage.is_some());
let u = completion.usage.unwrap();
assert_eq!(u.prompt_tokens, 10);
assert_eq!(u.completion_tokens, 5);
}
#[tokio::test]
async fn parse_sse_stream_returns_error_on_malformed_chunk() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {not valid json
event: message_stop
data: {\"type\":\"message_stop\"}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
provider.stream(&[Message::user("hi".to_string())], &[], None, None),
)
.await
.expect("SSE parser must not hang on malformed input");
let err = match result {
Ok(_) => panic!("expected Err on malformed SSE chunk, got Ok"),
Err(e) => e,
};
match err {
Error::Llm { provider, message } => {
assert!(
message.contains("SSE parse error"),
"error message should be diagnosable, got: {message}"
);
assert!(
provider.contains("claude-3-sonnet"),
"error should carry the provider/model name, got: {provider}"
);
}
other => panic!("expected Error::Llm, got {other:?}"),
}
}
#[tokio::test]
async fn parse_sse_stream_returns_partial_on_mid_stream_error() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" partial\"}}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
9999,
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await
.expect("partial content must be returned, not Err");
assert_eq!(
completion.content, "Hello partial",
"accumulated deltas must survive the mid-stream read error",
);
assert_eq!(
completion.finish_reason.as_deref(),
Some("interrupted"),
"partial completion must carry the interrupted sentinel",
);
assert!(
completion.tool_calls.is_empty(),
"half-received tool calls must be dropped from a partial completion",
);
}
#[tokio::test]
async fn test_e2_stream_thinking_deltas_populate_reasoning() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"deepseek-v4-flash\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Step one. \"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Step two.\"}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Done.\"}}
event: message_delta
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
event: message_stop
data: {\"type\":\"message_stop\"}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "deepseek-v4-flash")
.unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], Some(tx), None)
.await
.unwrap();
assert_eq!(completion.content, "Done.");
assert_eq!(
completion.reasoning_content.as_deref(),
Some("Step one. Step two.")
);
let mut chunks = Vec::new();
while let Ok(d) = rx.try_recv() {
chunks.push(d);
}
assert_eq!(
chunks,
vec![
StreamChunk::Reasoning("Step one. ".to_string()),
StreamChunk::Reasoning("Step two.".to_string()),
StreamChunk::Text("Done.".to_string()),
]
);
}
#[tokio::test]
async fn test_f_stream_tool_use_assembles_tool_calls() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":50,\"output_tokens\":0}}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_abc123\",\"name\":\"Read\",\"input\":{}}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"src/\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"lib.rs\\\"}\"}}
event: message_delta
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":30}}
event: message_stop
data: {\"type\":\"message_stop\"}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let completion = provider
.stream(
&[Message::user("read the file".to_string())],
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(completion.tool_calls.len(), 1);
let tc = &completion.tool_calls[0];
assert_eq!(tc.id, "call_abc123");
assert_eq!(tc.name, "Read");
assert_eq!(tc.arguments["path"], "src/lib.rs");
assert_eq!(completion.finish_reason.as_deref(), Some("tool_calls"));
}
#[tokio::test]
async fn test_g_stream_tx_receives_text_chunks() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"A\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"B\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"C\"}}
event: message_delta
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}
event: message_stop
data: {\"type\":\"message_stop\"}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], Some(tx), None)
.await
.unwrap();
assert_eq!(completion.content, "ABC");
let mut deltas = Vec::new();
while let Some(d) = rx.recv().await {
deltas.push(d);
if deltas.len() >= 3 {
break;
}
}
assert_eq!(
deltas,
vec![
StreamChunk::Text("A".to_string()),
StreamChunk::Text("B".to_string()),
StreamChunk::Text("C".to_string()),
]
);
}
#[tokio::test]
async fn test_h_stream_with_end_turn_produces_stop_reason() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
event: message_delta
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
event: message_stop
data: {\"type\":\"message_stop\"}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
).unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await
.unwrap();
assert_eq!(completion.content, "Hello");
assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
assert!(completion.usage.is_some());
}
#[test]
fn policy_retries_5xx_with_exponential_backoff() {
let policy = RetryPolicy::default();
assert_eq!(
policy.backoff_for(0, Some(503), false),
Some(Duration::from_secs(1))
);
assert_eq!(
policy.backoff_for(1, Some(500), false),
Some(Duration::from_secs(2))
);
assert_eq!(policy.backoff_for(2, Some(500), false), None);
}
#[test]
fn policy_retries_network_errors() {
let policy = RetryPolicy::default();
assert_eq!(
policy.backoff_for(0, None, true),
Some(Duration::from_secs(1))
);
}
#[test]
fn policy_does_not_retry_4xx() {
let policy = RetryPolicy::default();
assert_eq!(policy.backoff_for(0, Some(400), false), None);
assert_eq!(policy.backoff_for(0, Some(401), false), None);
assert_eq!(policy.backoff_for(0, Some(404), false), None);
}
#[test]
fn build_request_serializes_passed_specs_verbatim() {
let specs = vec![
ToolSpec {
name: "ToolSearchTool".to_string(),
description: "search".to_string(),
parameters: json!({"type": "object"}),
},
ToolSpec {
name: "Read".to_string(),
description: "Read a file".to_string(),
parameters: json!({"type": "object"}),
},
];
let body = build_request("claude-3", 0.2, 4096, None, &[Message::user("hi")], &specs);
let tools = body["tools"].as_array().expect("tools should be array");
assert_eq!(tools.len(), 2);
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"ToolSearchTool"));
assert!(names.contains(&"Read"));
for t in tools {
assert!(t["input_schema"].is_object());
}
}
#[test]
fn serialize_messages_encodes_toolsearch_result_as_tool_references() {
let msgs = vec![
Message::user("search for foo"),
Message::assistant_with_tool_calls(
String::new(),
vec![ToolCall {
id: "call_xyz".to_string(),
name: TOOL_SEARCH_TOOL_NAME.to_string(),
arguments: json!({"query": "foo"}),
}],
),
Message {
role: Role::User,
content: r#"["notebook_edit"]"#.to_string(),
tool_calls: Vec::new(),
tool_call_id: Some("call_xyz".to_string()),
reasoning_content: None,
is_compaction_summary: false,
},
];
let wire_msgs = serialize_messages_anthropic(&msgs);
assert_eq!(wire_msgs.len(), 3);
let last = &wire_msgs[2];
assert_eq!(last["role"], "user");
let content = last["content"].as_array().expect("content should be array");
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_xyz");
let inner = content[0]["content"]
.as_array()
.expect("inner content must be array");
assert_eq!(inner.len(), 1);
assert_eq!(inner[0]["type"], "tool_reference");
assert_eq!(inner[0]["tool_name"], "notebook_edit");
}
#[tokio::test]
async fn complete_sends_specs_and_returns_tool_call() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 8192];
let _ = stream.read(&mut buf);
let response_body = r#"{"type":"message","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{"path":"foo.txt"}}],"stop_reason":"tool_use","usage":{"input_tokens":50,"output_tokens":10}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body,
);
stream.write_all(response.as_bytes()).unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
let specs = vec![
ToolSpec {
name: "ToolSearchTool".to_string(),
description: "search for deferred tools".to_string(),
parameters: json!({"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}),
},
ToolSpec {
name: "Read".to_string(),
description: "Read a file".to_string(),
parameters: json!({"type": "object"}),
},
];
let result = provider
.complete(&[Message::user("read foo.txt")], &specs)
.await
.expect("should succeed");
assert_eq!(result.tool_calls.len(), 1);
assert_eq!(result.tool_calls[0].name, "Read");
}
#[test]
fn filter_leading_assistant_removes_multiple_empty_assistants() {
let msgs = vec![
Message::assistant("first empty".to_string()),
Message::assistant("second empty".to_string()),
Message::user("Hello".to_string()),
];
let filtered = filter_leading_assistant(&msgs);
assert_eq!(
filtered.len(),
1,
"both leading assistant messages must be removed"
);
assert_eq!(filtered[0].role, Role::User);
}
#[test]
fn filter_leading_assistant_noop_when_first_is_user() {
let msgs = vec![
Message::user("Hello".to_string()),
Message::assistant("Hi".to_string()),
];
let filtered = filter_leading_assistant(&msgs);
assert_eq!(
filtered.len(),
2,
"must not remove messages when first is user"
);
assert_eq!(filtered[0].role, Role::User);
}
#[test]
fn serialize_message_plain_user_has_role_user_and_content() {
let msg = Message::user("Hello world".to_string());
let v = serialize_message(&msg);
assert_eq!(v["role"].as_str(), Some("user"));
assert_eq!(v["content"].as_str(), Some("Hello world"));
}
#[test]
fn handle_text_delta_appends_content_and_skips_empty() {
let mut acc = SseAccum::default();
let delta = json!({ "type": "text_delta", "text": "hello" });
AnthropicProvider::handle_text_delta(&mut acc, &delta, &None);
assert_eq!(acc.content, "hello");
let empty_delta = json!({ "type": "text_delta", "text": "" });
AnthropicProvider::handle_text_delta(&mut acc, &empty_delta, &None);
assert_eq!(acc.content, "hello", "empty text must not change content");
}
#[test]
fn handle_input_json_delta_fills_tool_call_slot() {
let mut acc = SseAccum::default();
let delta = json!({ "type": "input_json_delta", "partial_json": r#"{"pa"# });
let parsed = json!({ "index": 0, "delta": delta });
AnthropicProvider::handle_input_json_delta(&mut acc, &delta, &parsed);
assert_eq!(acc.tool_calls.len(), 1);
assert_eq!(acc.tool_calls[0].partial_json, r#"{"pa"#);
let delta2 = json!({ "type": "input_json_delta", "partial_json": r#"th":"x"}"# });
let parsed2 = json!({ "index": 0, "delta": delta2 });
AnthropicProvider::handle_input_json_delta(&mut acc, &delta2, &parsed2);
assert_eq!(acc.tool_calls[0].partial_json, r#"{"path":"x"}"#);
}
#[test]
fn handle_thinking_delta_appends_reasoning_and_skips_empty() {
let mut acc = SseAccum::default();
let delta = json!({ "type": "thinking_delta", "thinking": "step 1" });
AnthropicProvider::handle_thinking_delta(&mut acc, &delta, &None);
assert_eq!(acc.reasoning_content, "step 1");
let empty = json!({ "type": "thinking_delta", "thinking": "" });
AnthropicProvider::handle_thinking_delta(&mut acc, &empty, &None);
assert_eq!(
acc.reasoning_content, "step 1",
"empty thinking must not change reasoning"
);
}
#[test]
fn supports_deferred_tools_default_false_and_env_override() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let provider = AnthropicProvider::new("http://third-party:9999", "sk-noop", "m").unwrap();
let orig = std::env::var("RECURSIVE_DEFERRED_TOOLS").ok();
std::env::remove_var("RECURSIVE_DEFERRED_TOOLS");
assert!(
!provider.supports_deferred_tools(),
"non-official base URL + env unset must be false"
);
std::env::set_var("RECURSIVE_DEFERRED_TOOLS", "1");
assert!(
provider.supports_deferred_tools(),
"env '1' must override to true"
);
std::env::set_var("RECURSIVE_DEFERRED_TOOLS", "true");
assert!(
provider.supports_deferred_tools(),
"env 'true' must override to true"
);
match orig {
Some(v) => std::env::set_var("RECURSIVE_DEFERRED_TOOLS", v),
None => std::env::remove_var("RECURSIVE_DEFERRED_TOOLS"),
}
}
#[test]
fn serialize_messages_keeps_user_message_after_tool_result() {
let msgs = vec![
Message::tool_result("call_1".to_string(), "done".to_string()),
Message::user("next question".to_string()),
];
let wire = serialize_messages_anthropic(&msgs);
assert_eq!(
wire.len(),
2,
"tool result AND following user message must both be serialized; got {wire:?}"
);
assert_eq!(wire[0]["role"], "user");
assert_eq!(wire[1]["role"], "user");
assert_eq!(wire[1]["content"], "next question");
}
#[tokio::test]
async fn post_with_retry_gives_up_after_max_transient_http_retries() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read, Write};
loop {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"error":"boom"}"#;
write!(
stream,
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
stream.flush().unwrap();
}
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
let mut provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
provider.retry = RetryPolicy {
max_retries: 2,
initial_backoff: Duration::from_millis(5),
max_backoff: Duration::from_millis(10),
};
let result = provider
.post_with_retry(&format!("http://{addr}/v1/messages"), &json!({}))
.await;
assert!(
result.is_err(),
"must give up after max_retries transient HTTP failures"
);
}
#[tokio::test]
async fn post_with_retry_gives_up_after_max_network_retries() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for _ in 0..3 {
if let Ok((stream, _)) = listener.accept() {
drop(stream);
}
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
let mut provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
provider.retry = RetryPolicy {
max_retries: 2,
initial_backoff: Duration::from_millis(5),
max_backoff: Duration::from_millis(10),
};
let result = provider
.post_with_retry(&format!("http://{addr}/v1/messages"), &json!({}))
.await;
assert!(
result.is_err(),
"must give up after max_retries network failures"
);
}
#[tokio::test]
async fn stream_inner_gives_up_after_max_transient_http_retries() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read, Write};
loop {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"error":"boom"}"#;
write!(
stream,
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
stream.flush().unwrap();
}
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
let mut provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
provider.retry = RetryPolicy {
max_retries: 2,
initial_backoff: Duration::from_millis(5),
max_backoff: Duration::from_millis(10),
};
let result = provider
.stream_inner(&[Message::user("hi".to_string())], &[], None, None)
.await;
assert!(
result.is_err(),
"stream_inner must give up after max_retries transient HTTP failures"
);
}
#[tokio::test]
async fn stream_inner_gives_up_after_max_network_retries() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for _ in 0..3 {
if let Ok((stream, _)) = listener.accept() {
drop(stream);
}
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
let mut provider =
AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
provider.retry = RetryPolicy {
max_retries: 2,
initial_backoff: Duration::from_millis(5),
max_backoff: Duration::from_millis(10),
};
let result = provider
.stream_inner(&[Message::user("hi".to_string())], &[], None, None)
.await;
assert!(
result.is_err(),
"stream_inner must give up after max_retries network failures"
);
}
#[tokio::test]
async fn parse_sse_stream_returns_partial_when_only_reasoning_accumulated() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"Let me reason\"}}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
9999,
body,
)
.unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(Duration::from_millis(50)).await;
let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await
.expect("reasoning-only partial must be returned, not Err");
assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
assert_eq!(completion.content, "", "no text content was streamed");
assert_eq!(
completion.reasoning_content.as_deref(),
Some("Let me reason"),
"accumulated thinking must survive the mid-stream error"
);
}
#[tokio::test]
async fn parse_sse_stream_returns_partial_when_only_tool_call_started() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"Read\",\"input\":{}}}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
9999,
body,
)
.unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(Duration::from_millis(50)).await;
let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await
.expect("tool-call-only partial must be returned, not Err");
assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
assert_eq!(completion.content, "", "no text content was streamed");
assert!(
completion.tool_calls.is_empty(),
"half-received tool calls must be dropped from a partial completion"
);
}
#[tokio::test]
async fn parse_sse_stream_processes_trailing_line_without_newline() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
event: content_block_delta
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
)
.unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(Duration::from_millis(50)).await;
let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await
.expect("stream should complete");
assert_eq!(
completion.content, "Hello world",
"trailing line without newline must still be processed"
);
}
#[tokio::test]
async fn build_completion_usage_from_input_tokens_only() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::{Read, Write};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "\
event: message_start
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}
event: content_block_start
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hi\"}}
";
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
)
.unwrap();
stream.flush().unwrap();
});
tokio::time::sleep(Duration::from_millis(50)).await;
let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
let completion = provider
.stream(&[Message::user("hi".to_string())], &[], None, None)
.await
.expect("stream should complete");
let usage = completion
.usage
.expect("usage must be present when input_tokens was reported");
assert_eq!(usage.prompt_tokens, 7);
}
#[test]
fn process_sse_line_thinking_block_start_populates_reasoning() {
let provider = AnthropicProvider::new("http://localhost:0", "sk-noop", "m").unwrap();
let mut acc = SseAccum::default();
provider
.process_sse_line("event: content_block_start", &mut acc, &None)
.unwrap();
let line = r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"initial thought"}}"#;
provider.process_sse_line(line, &mut acc, &None).unwrap();
assert_eq!(acc.reasoning_content, "initial thought");
}
#[test]
fn message_delta_usage_overrides_message_start_input() {
let provider = AnthropicProvider::new("http://localhost:0", "sk-noop", "m").unwrap();
let mut acc = SseAccum::default();
provider
.process_sse_line("event: message_start", &mut acc, &None)
.unwrap();
provider
.process_sse_line(
r#"data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"GLM-5.2","usage":{"input_tokens":0,"output_tokens":0}}}"#,
&mut acc,
&None,
)
.unwrap();
provider
.process_sse_line("event: message_delta", &mut acc, &None)
.unwrap();
provider
.process_sse_line(
r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":7680,"cache_creation_input_tokens":36}}"#,
&mut acc,
&None,
)
.unwrap();
let completion = AnthropicProvider::build_completion(acc, Some("end_turn".to_string()));
let usage = completion.usage.expect("delta 帧报了 usage,必须存在");
assert_eq!(usage.prompt_tokens, 17);
assert_eq!(usage.completion_tokens, 3);
assert_eq!(usage.cache_hit_tokens, 7680);
assert_eq!(usage.cache_miss_tokens, 17 + 36);
assert_eq!(usage.total_tokens, 20);
}
#[test]
fn message_delta_without_input_keys_keeps_official_semantics() {
let provider = AnthropicProvider::new("http://localhost:0", "sk-noop", "m").unwrap();
let mut acc = SseAccum::default();
provider
.process_sse_line("event: message_start", &mut acc, &None)
.unwrap();
provider
.process_sse_line(
r#"data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"claude-3","usage":{"input_tokens":50,"output_tokens":0,"cache_read_input_tokens":100}}}"#,
&mut acc,
&None,
)
.unwrap();
provider
.process_sse_line("event: message_delta", &mut acc, &None)
.unwrap();
provider
.process_sse_line(
r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":7}}"#,
&mut acc,
&None,
)
.unwrap();
let completion = AnthropicProvider::build_completion(acc, Some("end_turn".to_string()));
let usage = completion.usage.expect("message_start 报了 usage");
assert_eq!(usage.prompt_tokens, 50);
assert_eq!(usage.completion_tokens, 7);
assert_eq!(usage.cache_hit_tokens, 100);
}
}