use crate::error::LlmError;
use crate::types::{LlmClient, LlmDoneOutcome, LlmEvent, LlmRequest, LlmStream};
use async_trait::async_trait;
use futures::StreamExt;
use meerkat_core::schema::{CompiledSchema, SchemaError};
use meerkat_core::{ContentBlock, ImageData, Message, OutputSchema, StopReason, Usage};
use serde::Deserialize;
use serde_json::Value;
use std::time::Duration;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
const DEFAULT_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
const SSE_BUFFER_CAPACITY: usize = 4096;
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub struct AnthropicClient {
api_key: String,
base_url: String,
http: reqwest::Client,
connect_timeout: Duration,
request_timeout: Duration,
pool_idle_timeout: Duration,
}
pub struct AnthropicClientBuilder {
api_key: String,
base_url: String,
connect_timeout: Duration,
request_timeout: Duration,
pool_idle_timeout: Duration,
}
impl AnthropicClientBuilder {
pub fn new(api_key: String) -> Self {
Self {
api_key,
base_url: "https://api.anthropic.com".to_string(),
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
pool_idle_timeout: DEFAULT_POOL_IDLE_TIMEOUT,
}
}
pub fn base_url(mut self, url: String) -> Self {
self.base_url = url;
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn pool_idle_timeout(mut self, timeout: Duration) -> Self {
self.pool_idle_timeout = timeout;
self
}
pub fn build(self) -> Result<AnthropicClient, LlmError> {
let base_url = self.base_url;
let connect_timeout = self.connect_timeout;
let request_timeout = self.request_timeout;
let pool_idle_timeout = self.pool_idle_timeout;
#[cfg(not(target_arch = "wasm32"))]
let builder = reqwest::Client::builder()
.connect_timeout(connect_timeout)
.timeout(request_timeout)
.pool_idle_timeout(pool_idle_timeout)
.pool_max_idle_per_host(4)
.tcp_keepalive(Duration::from_secs(30));
#[cfg(target_arch = "wasm32")]
let builder = reqwest::Client::builder();
let http = crate::http::build_http_client_for_base_url(builder, &base_url)?;
Ok(AnthropicClient {
api_key: self.api_key,
base_url,
http,
connect_timeout,
request_timeout,
pool_idle_timeout,
})
}
}
impl AnthropicClient {
pub fn new(api_key: String) -> Result<Self, LlmError> {
AnthropicClientBuilder::new(api_key).build()
}
pub fn builder(api_key: String) -> AnthropicClientBuilder {
AnthropicClientBuilder::new(api_key)
}
pub fn from_env() -> Result<Self, LlmError> {
let api_key = std::env::var("RKAT_ANTHROPIC_API_KEY")
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
.map_err(|_| LlmError::InvalidApiKey)?;
Self::new(api_key)
}
pub fn with_base_url(mut self, url: String) -> Self {
#[cfg(not(target_arch = "wasm32"))]
let builder = reqwest::Client::builder()
.connect_timeout(self.connect_timeout)
.timeout(self.request_timeout)
.pool_idle_timeout(self.pool_idle_timeout)
.pool_max_idle_per_host(4)
.tcp_keepalive(Duration::from_secs(30));
#[cfg(target_arch = "wasm32")]
let builder = reqwest::Client::builder();
if let Ok(http) = crate::http::build_http_client_for_base_url(builder, &url) {
self.http = http;
}
self.base_url = url;
self
}
fn build_request_body(&self, request: &LlmRequest) -> Result<Value, LlmError> {
let mut messages = Vec::new();
let mut system_prompt = None;
for msg in &request.messages {
match msg {
Message::System(s) => {
system_prompt = Some(s.content.clone());
}
Message::SystemNotice(notice) => {
messages.push(serde_json::json!({
"role": "user",
"content": notice.rendered_text()
}));
}
Message::User(u) => {
if meerkat_core::has_non_text_content(&u.content) {
let content_array: Vec<Value> = u
.content
.iter()
.map(|block| match block {
ContentBlock::Text { text } => serde_json::json!({
"type": "text",
"text": text
}),
ContentBlock::Image { media_type, data } => match data {
ImageData::Inline { data } => serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
}),
ImageData::Blob { .. } => serde_json::json!({
"type": "text",
"text": block.text_projection()
}),
},
_ => serde_json::json!({
"type": "text",
"text": block.text_projection()
}),
})
.collect();
messages.push(serde_json::json!({
"role": "user",
"content": content_array
}));
} else {
messages.push(serde_json::json!({
"role": "user",
"content": u.text_content()
}));
}
}
Message::Assistant(a) => {
let mut content = Vec::new();
if !a.content.is_empty() {
content.push(serde_json::json!({
"type": "text",
"text": a.content
}));
}
for tc in &a.tool_calls {
content.push(serde_json::json!({
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": tc.args
}));
}
messages.push(serde_json::json!({
"role": "assistant",
"content": content
}));
}
Message::BlockAssistant(a) => {
let mut content = Vec::new();
for block in &a.blocks {
match block {
meerkat_core::AssistantBlock::Text { text, .. } => {
if !text.is_empty() {
content.push(serde_json::json!({
"type": "text",
"text": text
}));
}
}
meerkat_core::AssistantBlock::Reasoning { text, meta } => {
match meta.as_deref() {
Some(meerkat_core::ProviderMeta::Anthropic { signature }) => {
content.push(serde_json::json!({
"type": "thinking",
"thinking": text,
"signature": signature
}));
}
Some(meerkat_core::ProviderMeta::AnthropicRedacted {
data,
}) => {
content.push(serde_json::json!({
"type": "redacted_thinking",
"data": data
}));
}
Some(meerkat_core::ProviderMeta::AnthropicCompaction {
content: summary,
}) => {
content.push(serde_json::json!({
"type": "compaction",
"content": summary
}));
}
_ => {
tracing::warn!(
"thinking block missing Anthropic signature, skipping"
);
continue;
}
}
}
meerkat_core::AssistantBlock::ToolUse { id, name, args, .. } => {
let args_value: Value = serde_json::from_str(args.get())
.unwrap_or_else(|_| serde_json::json!({}));
content.push(serde_json::json!({
"type": "tool_use",
"id": id,
"name": name,
"input": args_value
}));
}
_ => {}
}
}
messages.push(serde_json::json!({
"role": "assistant",
"content": content
}));
}
Message::ToolResults { results } => {
let mut content = Vec::new();
for r in results {
if r.has_video() {
return Err(LlmError::InvalidRequest {
message: "video blocks are not supported in Anthropic tool results"
.to_string(),
});
}
if r.has_images() {
let result_content: Vec<Value> = r
.content
.iter()
.map(|block| match block {
ContentBlock::Text { text } => serde_json::json!({
"type": "text",
"text": text
}),
ContentBlock::Image {
media_type,
data: ImageData::Inline { data },
} => serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
}),
ContentBlock::Image { .. } => serde_json::json!({
"type": "text",
"text": block.text_projection()
}),
_ => serde_json::json!({
"type": "text",
"text": block.text_projection()
}),
})
.collect();
content.push(serde_json::json!({
"type": "tool_result",
"tool_use_id": r.tool_use_id,
"content": result_content,
"is_error": r.is_error
}));
} else {
content.push(serde_json::json!({
"type": "tool_result",
"tool_use_id": r.tool_use_id,
"content": r.text_content(),
"is_error": r.is_error
}));
}
}
messages.push(serde_json::json!({
"role": "user",
"content": content
}));
}
}
}
let mut body = serde_json::json!({
"model": request.model,
"max_tokens": request.max_tokens,
"messages": messages,
"stream": true
});
if let Some(system) = system_prompt {
body["system"] = Value::String(system);
}
if let Some(temp) = request.temperature
&& let Some(num) = serde_json::Number::from_f64(temp as f64)
{
body["temperature"] = Value::Number(num);
}
if !request.tools.is_empty() {
let tools: Vec<Value> = request
.tools
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"description": t.description,
"input_schema": t.input_schema
})
})
.collect();
body["tools"] = Value::Array(tools);
}
if let Some(ref params) = request.provider_params {
if let Some(thinking) = params.get("thinking") {
if thinking.get("type").and_then(|t| t.as_str()) == Some("adaptive") {
body["thinking"] = serde_json::json!({"type": "adaptive"});
} else if let Some(budget) = thinking
.get("budget_tokens")
.and_then(serde_json::Value::as_u64)
{
body["thinking"] = serde_json::json!({
"type": "enabled",
"budget_tokens": budget
});
}
} else if let Some(budget) = params
.get("thinking_budget")
.and_then(serde_json::Value::as_u64)
{
body["thinking"] = serde_json::json!({
"type": "enabled",
"budget_tokens": budget
});
}
if let Some(top_k) = params.get("top_k") {
let numeric_top_k = match top_k {
Value::Number(_) => Some(top_k.clone()),
Value::String(s) => s.parse::<u64>().ok().map(|n| Value::Number(n.into())),
_ => None,
};
if let Some(v) = numeric_top_k {
body["top_k"] = v;
}
}
if let Some(effort) = params.get("effort").and_then(|v| v.as_str()) {
if body.get("output_config").is_none() {
body["output_config"] = serde_json::json!({});
}
body["output_config"]["effort"] = Value::String(effort.to_string());
}
if let Some(structured) = params.get("structured_output") {
let output_schema: OutputSchema = serde_json::from_value(structured.clone())
.map_err(|e| LlmError::InvalidRequest {
message: format!("Invalid structured_output schema: {e}"),
})?;
let compiled =
self.compile_schema(&output_schema)
.map_err(|e| LlmError::InvalidRequest {
message: e.to_string(),
})?;
if body.get("output_config").is_none() {
body["output_config"] = serde_json::json!({});
}
body["output_config"]["format"] = serde_json::json!({
"type": "json_schema",
"schema": compiled.schema
});
}
if let Some(geo) = params.get("inference_geo").and_then(|v| v.as_str()) {
body["inference_geo"] = Value::String(geo.to_string());
}
if let Some(compaction) = params.get("compaction") {
if compaction.as_str() == Some("auto") {
body["context_management"] = serde_json::json!({
"edits": [{"type": "compact_20260112"}]
});
} else if compaction.is_object() {
let mut edit = serde_json::json!({"type": "compact_20260112"});
if let Some(obj) = compaction.as_object() {
for (k, v) in obj {
edit[k] = v.clone();
}
}
body["context_management"] = serde_json::json!({
"edits": [edit]
});
}
}
}
Ok(body)
}
fn parse_sse_line(line: &str) -> Option<AnthropicEvent> {
if let Some(data) = Self::strip_data_prefix(line) {
serde_json::from_str(data).ok()
} else {
None
}
}
fn strip_data_prefix(line: &str) -> Option<&str> {
line.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
.map(str::trim_start)
}
fn map_stop_reason(reason: &str) -> StopReason {
match reason {
"tool_use" => StopReason::ToolUse,
"max_tokens" => StopReason::MaxTokens,
"stop_sequence" => StopReason::StopSequence,
"content_filter" => StopReason::ContentFilter,
_ => StopReason::EndTurn,
}
}
}
fn ensure_additional_properties_false(value: &mut Value) {
match value {
Value::Object(obj) => {
let is_object_type = match obj.get("type") {
Some(Value::String(t)) => t == "object",
Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("object")),
_ => obj.contains_key("properties"),
};
if is_object_type
&& obj.contains_key("properties")
&& !obj.contains_key("additionalProperties")
{
obj.insert("additionalProperties".to_string(), Value::Bool(false));
}
let keys: Vec<String> = obj.keys().cloned().collect();
for key in keys {
if let Some(child) = obj.get_mut(&key) {
ensure_additional_properties_false(child);
}
}
}
Value::Array(items) => {
for item in items.iter_mut() {
ensure_additional_properties_false(item);
}
}
_ => {}
}
}
fn merge_usage(target: &mut Usage, update: &AnthropicUsage) {
if let Some(v) = update.input_tokens {
target.input_tokens = v;
}
if let Some(v) = update.output_tokens {
target.output_tokens = v;
}
if let Some(v) = update.cache_creation_input_tokens {
target.cache_creation_tokens = Some(v);
}
if let Some(v) = update.cache_read_input_tokens {
target.cache_read_tokens = Some(v);
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl LlmClient for AnthropicClient {
fn stream<'a>(&'a self, request: &'a LlmRequest) -> LlmStream<'a> {
let inner: LlmStream<'a> = Box::pin(async_stream::try_stream! {
let body = self.build_request_body(request)?;
let mut betas = Vec::new();
let thinking_type = body.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str());
if thinking_type == Some("enabled") {
betas.push("interleaved-thinking-2025-05-14");
}
if body.get("output_config").and_then(|c| c.get("format")).is_some() {
betas.push("structured-outputs-2025-11-13");
}
if let Some(ref params) = request.provider_params
&& params.get("context").and_then(|v| v.as_str()) == Some("1m")
{
betas.push("context-1m-2025-08-07");
}
if body.get("context_management").is_some() {
betas.push("compact-2026-01-12");
}
let mut req = self.http
.post(format!("{}/v1/messages", self.base_url))
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json");
if !betas.is_empty() {
req = req.header("anthropic-beta", betas.join(","));
}
#[cfg(target_arch = "wasm32")]
{
req = req.header("anthropic-dangerous-direct-browser-access", "true");
}
let response = req
.json(&body)
.send()
.await
.map_err(|_| LlmError::NetworkTimeout {
duration_ms: 30000,
})?;
let status_code = response.status().as_u16();
let stream_result = if (200..=299).contains(&status_code) {
Ok(response.bytes_stream())
} else {
let headers = response.headers().clone();
let text = response.text().await.unwrap_or_default();
Err(LlmError::from_http_response(status_code, text, &headers))
};
let mut stream = stream_result?;
let mut buffer = String::with_capacity(SSE_BUFFER_CAPACITY);
let mut current_tool_id: Option<String> = None;
let mut current_tool_name: Option<String> = None;
let mut accumulated_tool_args = String::new();
let mut current_block_type: Option<String> = None;
let mut current_thinking_signature: Option<String> = None;
let mut last_stop_reason: Option<StopReason> = None;
let mut usage = Usage::default();
let mut saw_done = false;
let mut saw_event = false;
macro_rules! handle_event {
($event:expr) => {
match $event.event_type.as_str() {
"content_block_delta" => {
if let Some(delta) = $event.delta {
match delta.delta_type.as_str() {
"text_delta" => {
if let Some(text) = delta.text {
saw_event = true;
yield LlmEvent::TextDelta { delta: text, meta: None };
}
}
"thinking_delta" => {
if let Some(text) = delta.thinking {
saw_event = true;
yield LlmEvent::ReasoningDelta { delta: text };
}
}
"signature_delta" => {
if let Some(sig) = delta.signature {
saw_event = true;
current_thinking_signature = Some(sig);
}
}
"input_json_delta" => {
if let Some(partial_json) = delta.partial_json {
accumulated_tool_args.push_str(&partial_json);
saw_event = true;
yield LlmEvent::ToolCallDelta {
id: current_tool_id.clone().unwrap_or_default(),
name: None,
args_delta: partial_json,
};
}
}
"compaction_delta" => {
if let Some(content) = delta.content {
saw_event = true;
yield LlmEvent::ReasoningComplete {
text: String::new(),
meta: Some(Box::new(meerkat_core::ProviderMeta::AnthropicCompaction { content })),
};
}
}
_ => {}
}
}
}
"content_block_start" => {
if let Some(content_block) = $event.content_block {
current_block_type = Some(content_block.block_type.clone());
match content_block.block_type.as_str() {
"thinking" => {
current_thinking_signature = content_block.signature;
}
"redacted_thinking" => {
if let Some(data) = content_block.data {
saw_event = true;
yield LlmEvent::ReasoningComplete {
text: String::new(),
meta: Some(Box::new(meerkat_core::ProviderMeta::AnthropicRedacted { data })),
};
}
}
"compaction" => {
}
"tool_use" => {
let id = content_block.id.unwrap_or_default();
current_tool_id = Some(id.clone());
current_tool_name = content_block.name.clone();
accumulated_tool_args.clear();
saw_event = true;
yield LlmEvent::ToolCallDelta {
id,
name: content_block.name,
args_delta: String::new(),
};
}
_ => {}
}
}
}
"content_block_stop" => {
match current_block_type.as_deref() {
Some("thinking") => {
let meta = current_thinking_signature
.take()
.map(|sig| Box::new(meerkat_core::ProviderMeta::Anthropic { signature: sig }));
yield LlmEvent::ReasoningComplete {
text: String::new(), meta,
};
}
Some("tool_use") => {
if let Some(ref tool_id) = current_tool_id {
let args_str = accumulated_tool_args.clone();
let args_val: serde_json::Value = serde_json::from_str(&args_str)
.unwrap_or(serde_json::json!({}));
saw_event = true;
yield LlmEvent::ToolCallComplete {
id: tool_id.clone(),
name: current_tool_name.take().unwrap_or_default(),
args: args_val,
meta: None,
};
accumulated_tool_args.clear();
}
current_tool_id = None;
}
_ => {}
}
current_block_type = None;
}
"message_delta" => {
if let Some(usage_update) = $event.usage {
merge_usage(&mut usage, &usage_update);
saw_event = true;
yield LlmEvent::UsageUpdate {
usage: usage.clone(),
};
}
if let Some(finish_reason) = $event.delta.and_then(|d| d.stop_reason) {
let reason = Self::map_stop_reason(finish_reason.as_str());
last_stop_reason = Some(reason);
if !saw_done {
saw_event = true;
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason: reason },
};
saw_done = true;
}
}
}
"message_start" => {
if let Some(usage_update) = $event.message.and_then(|m| m.usage) {
merge_usage(&mut usage, &usage_update);
saw_event = true;
yield LlmEvent::UsageUpdate {
usage: usage.clone(),
};
}
}
"message_stop" => {
if !saw_done {
let finish_reason = $event.delta.and_then(|d| d.stop_reason);
let reason = finish_reason
.as_deref()
.map(Self::map_stop_reason)
.or(last_stop_reason)
.unwrap_or(StopReason::EndTurn);
last_stop_reason = Some(reason);
saw_event = true;
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason: reason },
};
saw_done = true;
}
}
"error" => {
let error_msg = $event.error
.as_ref()
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("unknown streaming error");
let error_type = $event.error
.as_ref()
.and_then(|e| e.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("unknown");
tracing::error!(
error_type,
error_msg,
"Anthropic streaming error"
);
let error = match error_type {
"overloaded_error" => LlmError::ServerOverloaded,
"rate_limit_error" => LlmError::RateLimited { retry_after_ms: None },
"api_error" => LlmError::ServerError {
status: 500,
message: error_msg.to_string(),
},
_ => LlmError::Unknown {
message: format!("{error_type}: {error_msg}"),
},
};
if !saw_done {
saw_event = true;
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Error { error },
};
saw_done = true;
}
}
_ => {}
}
};
}
macro_rules! handle_line {
($line:expr) => {
if !$line.is_empty() {
if let Some(data) = Self::strip_data_prefix($line) {
if data == "[DONE]" {
if !saw_done {
let reason =
last_stop_reason.unwrap_or(StopReason::EndTurn);
saw_event = true;
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason: reason },
};
saw_done = true;
}
} else if let Some(event) = Self::parse_sse_line($line) {
handle_event!(event);
}
}
}
};
}
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| LlmError::ConnectionReset)?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim();
handle_line!(line);
buffer.drain(..=newline_pos);
}
}
if !buffer.is_empty() {
for line in buffer.lines() {
let line = line.trim();
handle_line!(line);
}
}
if !saw_done && saw_event {
tracing::warn!(
model = %request.model,
"Anthropic stream ended without terminal event; emitting synthetic Done"
);
let reason = last_stop_reason.unwrap_or(StopReason::EndTurn);
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason: reason },
};
}
});
crate::streaming::ensure_terminal_done(inner)
}
fn provider(&self) -> &'static str {
"anthropic"
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
let mut schema = output_schema.schema.as_value().clone();
ensure_additional_properties_false(&mut schema);
Ok(CompiledSchema {
schema,
warnings: Vec::new(),
})
}
}
#[derive(Debug, Deserialize)]
struct AnthropicEvent {
#[serde(rename = "type")]
event_type: String,
delta: Option<AnthropicDelta>,
content_block: Option<AnthropicContentBlock>,
message: Option<AnthropicMessage>,
usage: Option<AnthropicUsage>,
error: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct AnthropicDelta {
#[serde(rename = "type", default)]
delta_type: String,
text: Option<String>,
partial_json: Option<String>,
stop_reason: Option<String>,
thinking: Option<String>,
signature: Option<String>,
content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicContentBlock {
#[serde(rename = "type")]
block_type: String,
id: Option<String>,
name: Option<String>,
signature: Option<String>,
data: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicMessage {
usage: Option<AnthropicUsage>,
}
#[derive(Debug, Deserialize)]
struct AnthropicUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
cache_read_input_tokens: Option<u64>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use meerkat_core::{
AssistantBlock, BlockAssistantMessage, ContentBlock, ProviderMeta, UserMessage,
};
#[test]
fn test_anthropic_content_block_parses_thinking_type() {
let json = r#"{"type": "thinking", "thinking": "Let me analyze..."}"#;
let block: AnthropicContentBlock = serde_json::from_str(json).unwrap();
assert_eq!(block.block_type, "thinking");
}
#[test]
fn test_anthropic_content_block_parses_thinking_with_signature() {
let json = r#"{"type": "thinking", "thinking": "", "signature": "sig_abc123"}"#;
let block: AnthropicContentBlock = serde_json::from_str(json).unwrap();
assert_eq!(block.block_type, "thinking");
assert_eq!(block.signature.as_deref(), Some("sig_abc123"));
}
#[test]
fn test_anthropic_delta_parses_thinking_delta() {
let json = r#"{"type": "thinking_delta", "thinking": "I need to consider..."}"#;
let delta: AnthropicDelta = serde_json::from_str(json).unwrap();
assert_eq!(delta.delta_type, "thinking_delta");
assert_eq!(delta.thinking.as_deref(), Some("I need to consider..."));
}
#[test]
fn test_anthropic_delta_parses_signature_delta() {
let json = r#"{"type": "signature_delta", "signature": "sig_xyz789"}"#;
let delta: AnthropicDelta = serde_json::from_str(json).unwrap();
assert_eq!(delta.delta_type, "signature_delta");
assert_eq!(delta.signature.as_deref(), Some("sig_xyz789"));
}
#[test]
fn test_anthropic_delta_parses_text_delta_unchanged() {
let json = r#"{"type": "text_delta", "text": "Hello world"}"#;
let delta: AnthropicDelta = serde_json::from_str(json).unwrap();
assert_eq!(delta.delta_type, "text_delta");
assert_eq!(delta.text.as_deref(), Some("Hello world"));
assert!(delta.thinking.is_none());
assert!(delta.signature.is_none());
}
#[test]
fn test_anthropic_delta_parses_input_json_delta_unchanged() {
let json = r#"{"type": "input_json_delta", "partial_json": "{\"path\":"}"#;
let delta: AnthropicDelta = serde_json::from_str(json).unwrap();
assert_eq!(delta.delta_type, "input_json_delta");
assert_eq!(delta.partial_json.as_deref(), Some("{\"path\":"));
}
#[test]
fn test_build_request_body_renders_thinking_block_with_signature()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let assistant_msg = Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: "Let me analyze this problem...".to_string(),
meta: Some(Box::new(ProviderMeta::Anthropic {
signature: "sig_abc123".to_string(),
})),
},
AssistantBlock::Text {
text: "The answer is 42.".to_string(),
meta: None,
},
],
stop_reason: StopReason::EndTurn,
});
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![
Message::User(UserMessage::text(
"What is the meaning of life?".to_string(),
)),
assistant_msg,
Message::User(UserMessage::text("Can you elaborate?".to_string())),
],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let assistant_content = messages[1]["content"].as_array().unwrap();
assert_eq!(assistant_content.len(), 2);
assert_eq!(assistant_content[0]["type"], "thinking");
assert_eq!(
assistant_content[0]["thinking"],
"Let me analyze this problem..."
);
assert_eq!(assistant_content[0]["signature"], "sig_abc123");
assert_eq!(assistant_content[1]["type"], "text");
assert_eq!(assistant_content[1]["text"], "The answer is 42.");
Ok(())
}
#[test]
fn test_build_request_body_degrades_video_user_content_to_text()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::with_blocks(vec![
ContentBlock::Video {
media_type: "video/mp4".to_string(),
duration_ms: 12_000,
data: meerkat_core::VideoData::Inline {
data: "AAAA".to_string(),
},
},
]))],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().ok_or("missing messages")?;
let content = messages[0]["content"]
.as_array()
.ok_or("missing content array")?;
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "[video: video/mp4]");
Ok(())
}
#[test]
fn test_build_request_body_rejects_video_tool_results() {
let client = AnthropicClient::new("test-key".to_string()).expect("client");
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::ToolResults {
results: vec![meerkat_core::ToolResult::with_blocks(
"tool_1".to_string(),
vec![ContentBlock::Video {
media_type: "video/mp4".to_string(),
duration_ms: 12_000,
data: meerkat_core::VideoData::Inline {
data: "AAAA".to_string(),
},
}],
false,
)],
}],
);
let err = client
.build_request_body(&request)
.expect_err("video tool results should be rejected");
match err {
LlmError::InvalidRequest { message } => {
assert!(message.contains("video blocks are not supported"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn test_build_request_body_skips_thinking_block_without_signature()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let assistant_msg = Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: "Some thinking...".to_string(),
meta: None, },
AssistantBlock::Text {
text: "The answer.".to_string(),
meta: None,
},
],
stop_reason: StopReason::EndTurn,
});
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![
Message::User(UserMessage::text("Question".to_string())),
assistant_msg,
],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let assistant_content = messages[1]["content"].as_array().unwrap();
assert_eq!(assistant_content.len(), 1);
assert_eq!(assistant_content[0]["type"], "text");
Ok(())
}
#[test]
fn test_build_request_body_renders_tool_use_blocks() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let assistant_msg = Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Text {
text: "I'll read that file for you.".to_string(),
meta: None,
},
AssistantBlock::ToolUse {
id: "tu_123".to_string(),
name: "read_file".to_string(),
args: serde_json::value::RawValue::from_string(
r#"{"path": "/tmp/test.txt"}"#.to_string(),
)
.unwrap(),
meta: None, },
],
stop_reason: StopReason::ToolUse,
});
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![
Message::User(UserMessage::text("Read /tmp/test.txt".to_string())),
assistant_msg,
],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let assistant_content = messages[1]["content"].as_array().unwrap();
assert_eq!(assistant_content.len(), 2);
assert_eq!(assistant_content[0]["type"], "text");
assert_eq!(assistant_content[1]["type"], "tool_use");
assert_eq!(assistant_content[1]["id"], "tu_123");
assert_eq!(assistant_content[1]["name"], "read_file");
assert_eq!(assistant_content[1]["input"]["path"], "/tmp/test.txt");
Ok(())
}
#[test]
fn test_build_request_body_adds_thinking_beta_header() -> Result<(), Box<dyn std::error::Error>>
{
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("thinking_budget", 10000);
let body = client.build_request_body(&request)?;
assert!(body.get("thinking").is_some());
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 10000);
Ok(())
}
#[test]
fn test_build_request_body_basic() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-20250514",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("thinking_budget", 10000);
let body = client.build_request_body(&request)?;
assert!(
body.get("thinking").is_some(),
"thinking field should be present"
);
let thinking = &body["thinking"];
assert_eq!(thinking["type"], "enabled");
assert_eq!(thinking["budget_tokens"], 10000);
Ok(())
}
#[test]
fn test_build_request_body_with_top_k() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-20250514",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("top_k", 40);
let body = client.build_request_body(&request)?;
assert_eq!(body["top_k"], 40);
Ok(())
}
#[test]
fn test_build_request_body_no_provider_params() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-20250514",
vec![Message::User(UserMessage::text("test".to_string()))],
);
let body = client.build_request_body(&request)?;
assert!(body.get("thinking").is_none());
assert!(body.get("top_k").is_none());
assert_eq!(body["model"], "claude-sonnet-4-20250514");
Ok(())
}
#[test]
fn test_client_builder_creates_configured_client() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::builder("test-key".to_string())
.connect_timeout(std::time::Duration::from_secs(5))
.request_timeout(std::time::Duration::from_secs(120))
.build()?;
assert_eq!(client.provider(), "anthropic");
Ok(())
}
#[test]
fn test_client_default_has_connection_pool() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
assert_eq!(client.provider(), "anthropic");
Ok(())
}
#[test]
fn test_parse_sse_line() {
let line = r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
let event = AnthropicClient::parse_sse_line(line);
assert!(event.is_some());
assert_eq!(event.unwrap().event_type, "content_block_delta");
let done = AnthropicClient::parse_sse_line("data: [DONE]");
assert!(done.is_none());
let comment = AnthropicClient::parse_sse_line(": comment");
assert!(comment.is_none());
let event_line = AnthropicClient::parse_sse_line("event: message_start");
assert!(event_line.is_none());
}
#[test]
fn test_sse_buffer_constants() {
let buffer_cap = SSE_BUFFER_CAPACITY;
assert!(buffer_cap >= 256, "SSE buffer should be at least 256 bytes");
let connect_timeout = DEFAULT_CONNECT_TIMEOUT.as_secs();
assert!(
connect_timeout >= 5,
"Connect timeout should be at least 5s"
);
let request_timeout = DEFAULT_REQUEST_TIMEOUT.as_secs();
assert!(
request_timeout >= 60,
"Request timeout should be at least 60s"
);
}
#[test]
fn test_regression_top_k_string_coercion() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-20250514",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("top_k", "40");
let body = client.build_request_body(&request)?;
assert!(
body["top_k"].is_number(),
"top_k should be a number, not string"
);
assert_eq!(body["top_k"], 40);
Ok(())
}
#[test]
fn test_regression_top_k_invalid_string_ignored() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-20250514",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("top_k", "not_a_number");
let body = client.build_request_body(&request)?;
assert!(
body.get("top_k").is_none(),
"invalid top_k should be ignored"
);
Ok(())
}
#[test]
fn test_usage_merge_preserves_input_tokens() {
let mut usage = Usage::default();
let start = AnthropicUsage {
input_tokens: Some(120),
output_tokens: Some(0),
cache_creation_input_tokens: Some(4),
cache_read_input_tokens: Some(2),
};
merge_usage(&mut usage, &start);
assert_eq!(usage.input_tokens, 120);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.cache_creation_tokens, Some(4));
assert_eq!(usage.cache_read_tokens, Some(2));
let delta = AnthropicUsage {
input_tokens: None,
output_tokens: Some(7),
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
};
merge_usage(&mut usage, &delta);
assert_eq!(usage.input_tokens, 120);
assert_eq!(usage.output_tokens, 7);
assert_eq!(usage.cache_creation_tokens, Some(4));
assert_eq!(usage.cache_read_tokens, Some(2));
}
#[test]
fn test_build_request_body_with_structured_output() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"]
});
let request = LlmRequest::new(
"claude-sonnet-4-20250514",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": schema,
"name": "person",
"strict": true
}),
);
let body = client.build_request_body(&request)?;
assert!(
body.get("output_config").is_some(),
"output_config should be present"
);
let output_config = &body["output_config"];
assert_eq!(output_config["format"]["type"], "json_schema");
assert!(output_config["format"]["schema"].is_object());
assert_eq!(
output_config["format"]["schema"]["additionalProperties"],
serde_json::json!(false)
);
Ok(())
}
#[test]
fn test_build_request_body_without_structured_output() -> Result<(), Box<dyn std::error::Error>>
{
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-20250514",
vec![Message::User(UserMessage::text("test".to_string()))],
);
let body = client.build_request_body(&request)?;
assert!(
body.get("output_config").is_none(),
"output_config should not be present without structured_output"
);
Ok(())
}
#[test]
fn test_compile_schema_recursively_injects_additional_properties()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let schema = serde_json::json!({
"type": "object",
"properties": {
"profile": {
"type": "object",
"properties": {
"name": {"type": "string"}
}
},
"rows": {
"type": "array",
"items": {
"type": "object",
"properties": {
"col": {"type": "integer"}
}
}
},
"union": {
"anyOf": [
{
"type": ["object", "null"],
"properties": {
"ok": {"type": "boolean"}
}
},
{"type": "string"}
]
}
}
});
let output_schema = OutputSchema::new(schema)?.strict();
let compiled = client.compile_schema(&output_schema)?;
assert!(compiled.warnings.is_empty());
assert_eq!(compiled.schema["additionalProperties"], false);
assert_eq!(
compiled.schema["properties"]["profile"]["additionalProperties"],
false
);
assert_eq!(
compiled.schema["properties"]["rows"]["items"]["additionalProperties"],
false
);
assert_eq!(
compiled.schema["properties"]["union"]["anyOf"][0]["additionalProperties"],
false
);
Ok(())
}
#[test]
fn test_compile_schema_preserves_explicit_additional_properties()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let schema = serde_json::json!({
"type": "object",
"additionalProperties": true,
"properties": {
"nested": {
"type": "object",
"additionalProperties": {"type": "string"},
"properties": {
"x": {"type": "string"}
}
},
"auto": {
"type": "object",
"properties": {
"y": {"type": "number"}
}
}
}
});
let output_schema = OutputSchema::new(schema)?;
let compiled = client.compile_schema(&output_schema)?;
assert!(compiled.warnings.is_empty());
assert_eq!(compiled.schema["additionalProperties"], true);
assert_eq!(
compiled.schema["properties"]["nested"]["additionalProperties"],
serde_json::json!({"type": "string"})
);
assert_eq!(
compiled.schema["properties"]["auto"]["additionalProperties"],
false
);
Ok(())
}
#[test]
fn test_build_request_body_adaptive_thinking() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("thinking", serde_json::json!({"type": "adaptive"}));
let body = client.build_request_body(&request)?;
assert!(
body.get("thinking").is_some(),
"thinking field should be present"
);
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(body["thinking"].get("budget_tokens").is_none());
Ok(())
}
#[test]
fn test_build_request_body_effort_parameter() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("effort", "medium");
let body = client.build_request_body(&request)?;
assert!(
body.get("output_config").is_some(),
"output_config should be present"
);
assert_eq!(body["output_config"]["effort"], "medium");
Ok(())
}
#[test]
fn test_build_request_body_effort_with_structured_output()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
});
let mut request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text("test".to_string()))],
);
request.provider_params = Some(serde_json::json!({
"effort": "high",
"structured_output": {
"schema": schema,
"name": "output",
"strict": true
}
}));
let body = client.build_request_body(&request)?;
let output_config = &body["output_config"];
assert_eq!(output_config["effort"], "high");
assert_eq!(output_config["format"]["type"], "json_schema");
assert!(output_config["format"]["schema"].is_object());
Ok(())
}
#[test]
fn test_build_request_body_adaptive_thinking_does_not_set_interleaved_beta()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("thinking", serde_json::json!({"type": "adaptive"}));
let body = client.build_request_body(&request)?;
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(
body["thinking"].get("budget_tokens").is_none(),
"adaptive thinking should not have budget_tokens"
);
Ok(())
}
#[test]
fn test_build_request_body_legacy_thinking_still_works()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("thinking_budget", 10000);
let body = client.build_request_body(&request)?;
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 10000);
Ok(())
}
#[test]
fn test_build_request_body_inference_geo() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("inference_geo", "us");
let body = client.build_request_body(&request)?;
assert_eq!(body["inference_geo"], "us");
Ok(())
}
#[test]
fn test_build_request_body_compaction_auto() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("compaction", "auto");
let body = client.build_request_body(&request)?;
let edits = body["context_management"]["edits"].as_array().unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0]["type"], "compact_20260112");
Ok(())
}
#[test]
fn test_build_request_body_compaction_with_trigger() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let mut request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text("test".to_string()))],
);
request.provider_params = Some(serde_json::json!({
"compaction": {
"trigger": {"type": "input_tokens", "value": 100_000}
}
}));
let body = client.build_request_body(&request)?;
let edits = body["context_management"]["edits"].as_array().unwrap();
assert_eq!(edits[0]["type"], "compact_20260112");
assert_eq!(edits[0]["trigger"]["value"], 100_000);
Ok(())
}
#[test]
fn test_build_request_body_redacted_thinking_roundtrip()
-> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let assistant_msg = Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: String::new(),
meta: Some(Box::new(ProviderMeta::AnthropicRedacted {
data: "encrypted_blob_abc123".to_string(),
})),
},
AssistantBlock::Text {
text: "The answer.".to_string(),
meta: None,
},
],
stop_reason: StopReason::EndTurn,
});
let request = LlmRequest::new(
"claude-opus-4-6",
vec![
Message::User(UserMessage::text("Question".to_string())),
assistant_msg,
],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let assistant_content = messages[1]["content"].as_array().unwrap();
assert_eq!(assistant_content[0]["type"], "redacted_thinking");
assert_eq!(assistant_content[0]["data"], "encrypted_blob_abc123");
assert_eq!(assistant_content[1]["type"], "text");
Ok(())
}
#[test]
fn test_build_request_body_compaction_block_roundtrip() -> Result<(), Box<dyn std::error::Error>>
{
let client = AnthropicClient::new("test-key".to_string())?;
let assistant_msg = Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![
AssistantBlock::Reasoning {
text: String::new(),
meta: Some(Box::new(ProviderMeta::AnthropicCompaction {
content: "Summary of prior conversation...".to_string(),
})),
},
AssistantBlock::Text {
text: "Continuing from summary.".to_string(),
meta: None,
},
],
stop_reason: StopReason::EndTurn,
});
let request = LlmRequest::new(
"claude-opus-4-6",
vec![
Message::User(UserMessage::text("Continue".to_string())),
assistant_msg,
],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let assistant_content = messages[1]["content"].as_array().unwrap();
assert_eq!(assistant_content[0]["type"], "compaction");
assert_eq!(
assistant_content[0]["content"],
"Summary of prior conversation..."
);
assert_eq!(assistant_content[1]["type"], "text");
Ok(())
}
use axum::{Router, extract::State, response::IntoResponse, routing::post};
use tokio::net::TcpListener;
async fn messages_sse(State(payload): State<String>) -> impl IntoResponse {
([("content-type", "text/event-stream")], payload)
}
async fn spawn_anthropic_stub_server(payload: String) -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new()
.route("/v1/messages", post(messages_sse))
.with_state(payload);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("local addr");
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve test server");
});
(format!("http://{addr}"), handle)
}
#[tokio::test]
async fn test_regression_message_delta_stop_reason_without_type_yields_done() {
let payload = [
r#"data: {"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":0}}}"#,
r#"data: {"type":"content_block_start","content_block":{"type":"text","text":""}}"#,
r#"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}"#,
r#"data: {"type":"content_block_stop"}"#,
r#"data: {"type":"message_delta","usage":{"output_tokens":5},"delta":{"stop_reason":"end_turn"}}"#,
"",
]
.join("\n");
let (base_url, server) = spawn_anthropic_stub_server(payload).await;
let client = AnthropicClient::builder("test-key".to_string())
.base_url(base_url)
.build()
.unwrap();
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::text("hello".to_string()))],
);
let mut stream = client.stream(&request);
let mut saw_text = false;
let mut saw_done = false;
let mut done_is_success = false;
while let Some(event) = stream.next().await {
match event.expect("stream event") {
LlmEvent::TextDelta { delta, .. } if delta == "Hello" => saw_text = true,
LlmEvent::Done { outcome } => {
saw_done = true;
done_is_success = matches!(outcome, LlmDoneOutcome::Success { .. });
break;
}
_ => {}
}
}
server.abort();
assert!(saw_text, "Expected text delta");
assert!(
saw_done,
"Expected Done event from message_delta stop_reason"
);
assert!(done_is_success, "Expected successful Done outcome");
}
#[tokio::test]
async fn test_regression_anthropic_error_event_yields_done_with_error() {
let payload = [
r#"data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
"",
]
.join("\n");
let (base_url, server) = spawn_anthropic_stub_server(payload).await;
let client = AnthropicClient::builder("test-key".to_string())
.base_url(base_url)
.build()
.unwrap();
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::text("hello".to_string()))],
);
let mut stream = client.stream(&request);
let mut saw_error_done = false;
while let Some(event) = stream.next().await {
if let LlmEvent::Done {
outcome: LlmDoneOutcome::Error { error },
} = event.expect("stream event")
{
assert!(
matches!(error, LlmError::ServerOverloaded),
"expected ServerOverloaded, got: {error:?}"
);
saw_error_done = true;
break;
}
}
server.abort();
assert!(saw_error_done, "Expected Done with error outcome");
}
#[tokio::test]
async fn test_normal_stream_with_message_stop_yields_done() {
let payload = [
r#"data: {"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":0}}}"#,
r#"data: {"type":"content_block_start","content_block":{"type":"text","text":""}}"#,
r#"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}"#,
r#"data: {"type":"content_block_stop"}"#,
r#"data: {"type":"message_delta","usage":{"output_tokens":3},"delta":{"stop_reason":"end_turn"}}"#,
r#"data: {"type":"message_stop"}"#,
"",
]
.join("\n");
let (base_url, server) = spawn_anthropic_stub_server(payload).await;
let client = AnthropicClient::builder("test-key".to_string())
.base_url(base_url)
.build()
.unwrap();
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::text("hello".to_string()))],
);
let mut stream = client.stream(&request);
let mut done_count = 0;
while let Some(event) = stream.next().await {
if let LlmEvent::Done { .. } = event.expect("stream event") {
done_count += 1;
break;
}
}
server.abort();
assert_eq!(done_count, 1, "Expected exactly one Done event");
}
#[test]
fn anthropic_text_only_tool_result_as_string() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![
Message::User(UserMessage::text("Read the file")),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::new(
"tu_1".to_string(),
"file contents here".to_string(),
false,
)],
},
],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let tool_msg = &messages[1]["content"].as_array().unwrap()[0];
assert_eq!(tool_msg["type"], "tool_result");
assert_eq!(tool_msg["tool_use_id"], "tu_1");
assert_eq!(tool_msg["content"], "file contents here");
assert!(
tool_msg["content"].is_string(),
"text-only tool result content should be a string"
);
Ok(())
}
#[test]
fn anthropic_image_tool_result_as_array() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![
Message::User(UserMessage::text("Take a screenshot")),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::with_blocks(
"tu_2".to_string(),
vec![
ContentBlock::Text {
text: "screenshot taken".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "iVBOR...".into(),
},
],
false,
)],
},
],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let tool_msg = &messages[1]["content"].as_array().unwrap()[0];
assert_eq!(tool_msg["type"], "tool_result");
assert_eq!(tool_msg["tool_use_id"], "tu_2");
let content = tool_msg["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "screenshot taken");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/png");
assert_eq!(content[1]["source"]["data"], "iVBOR...");
assert!(
!serde_json::to_string(&body)
.unwrap()
.contains("source_path"),
"source_path must never appear in provider payload"
);
assert!(
!serde_json::to_string(&body)
.unwrap()
.contains("/tmp/screenshot.png"),
"source_path value must never appear in provider payload"
);
Ok(())
}
#[test]
fn anthropic_user_message_with_image() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "describe this".to_string(),
},
ContentBlock::Image {
media_type: "image/jpeg".to_string(),
data: "/9j/4AAQ...".into(),
},
]))],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
let user_msg = &messages[0];
assert_eq!(user_msg["role"], "user");
let content = user_msg["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "describe this");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/jpeg");
assert_eq!(content[1]["source"]["data"], "/9j/4AAQ...");
assert!(
!serde_json::to_string(&body)
.unwrap()
.contains("source_path"),
"source_path must never appear in provider payload"
);
Ok(())
}
#[test]
fn anthropic_text_only_user_message_stays_string() -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::new("test-key".to_string())?;
let request = LlmRequest::new(
"claude-sonnet-4-5",
vec![Message::User(UserMessage::text("just text"))],
);
let body = client.build_request_body(&request)?;
let messages = body["messages"].as_array().unwrap();
assert!(
messages[0]["content"].is_string(),
"text-only user message content should be a string"
);
assert_eq!(messages[0]["content"], "just text");
Ok(())
}
}