use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use futures::StreamExt;
use reqwest::Client;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::constants::MAX_RESPONSE_CHARS;
use crate::models::ModelCapabilities;
use crate::models::config::ModelConfig;
use crate::models::error::{BackendError, ModelError, Result};
use crate::models::reasoning::{
ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
};
use crate::models::stream::{StreamCallback, StreamEvent};
use crate::models::tool_call::{FunctionCall, ToolCall};
use crate::models::traits::Model;
use crate::models::types::{ChatMessage, MessageRole, ModelResponse, TokenUsage};
use crate::utils::drain_sse_events;
const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
const ANTHROPIC_VERSION: &str = "2023-06-01";
fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
if *truncated {
return;
}
buf.push_str(chunk);
if buf.len() > cap {
let end = buf.floor_char_boundary(cap);
buf.truncate(end);
buf.push_str(TRUNCATION_MARKER);
*truncated = true;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThinkingFormat {
Adaptive,
Legacy,
}
fn thinking_format_for(model: &str) -> ThinkingFormat {
let m = model.to_lowercase();
if m.starts_with("claude-opus-4-7")
|| m.starts_with("claude-sonnet-4-6")
|| m.starts_with("claude-opus-4-6")
{
ThinkingFormat::Adaptive
} else {
ThinkingFormat::Legacy
}
}
fn legacy_budget_for(level: ReasoningLevel, max_tokens: usize) -> Option<u32> {
let proposed: u32 = match level {
ReasoningLevel::None => return None,
ReasoningLevel::Minimal | ReasoningLevel::Low => 2048,
ReasoningLevel::Medium => 4096,
ReasoningLevel::High => 16000,
ReasoningLevel::XHigh => 24000,
ReasoningLevel::Max => 32000,
};
let ceiling = max_tokens.saturating_sub(1024) as u32;
Some(proposed.min(ceiling).max(1024))
}
fn supports_max_effort(model: &str) -> bool {
let m = model.to_lowercase();
m.starts_with("claude-opus-4-7")
|| m.starts_with("claude-opus-4-6")
|| m.starts_with("claude-sonnet-4-6")
|| m.starts_with("claude-mythos")
}
fn supports_xhigh_effort(model: &str) -> bool {
model.to_lowercase().starts_with("claude-opus-4-7")
}
fn adaptive_effort_for(level: ReasoningLevel, model: &str) -> Option<&'static str> {
match level {
ReasoningLevel::None => None,
ReasoningLevel::Minimal | ReasoningLevel::Low => Some("low"),
ReasoningLevel::Medium => Some("medium"),
ReasoningLevel::High => Some("high"),
ReasoningLevel::XHigh => {
if supports_xhigh_effort(model) {
Some("xhigh")
} else {
Some("high")
}
},
ReasoningLevel::Max => {
if supports_max_effort(model) {
Some("max")
} else {
Some("high")
}
},
}
}
fn to_anthropic_tools(openai_tools: &[&Value]) -> Vec<Value> {
openai_tools
.iter()
.filter_map(|tool| {
let function = tool.get("function")?;
let name = function.get("name")?.as_str()?;
let description = function
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("");
let input_schema = function.get("parameters").cloned().unwrap_or(json!({
"type": "object",
"properties": {}
}));
Some(json!({
"type": "custom",
"name": name,
"description": description,
"input_schema": input_schema,
}))
})
.collect()
}
fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<Value>) {
let mut system: Option<String> = None;
let mut out: Vec<Value> = Vec::new();
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
match msg.role {
MessageRole::System => {
if system.is_none() {
system = Some(msg.content.clone());
}
i += 1;
},
MessageRole::User => {
let mut content_blocks: Vec<Value> = Vec::new();
if !msg.content.is_empty() {
content_blocks.push(json!({
"type": "text",
"text": msg.content,
}));
}
if let Some(ref images) = msg.images {
for data in images {
content_blocks.push(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": data,
},
}));
}
}
let content = if content_blocks.len() == 1 && content_blocks[0]["type"] == "text" {
content_blocks[0]["text"].clone()
} else if content_blocks.is_empty() {
json!("")
} else {
json!(content_blocks)
};
out.push(json!({"role": "user", "content": content}));
i += 1;
},
MessageRole::Assistant => {
let mut content_blocks: Vec<Value> = Vec::new();
if let Some(ref thinking) = msg.thinking
&& !thinking.is_empty()
{
let mut thinking_block = json!({
"type": "thinking",
"thinking": thinking,
});
if let Some(ref sig) = msg.thinking_signature {
thinking_block["signature"] = json!(sig);
}
content_blocks.push(thinking_block);
}
if !msg.content.is_empty() {
content_blocks.push(json!({
"type": "text",
"text": msg.content,
}));
}
if let Some(ref tool_calls) = msg.tool_calls {
for tc in tool_calls {
content_blocks.push(json!({
"type": "tool_use",
"id": tc.id.clone().unwrap_or_default(),
"name": tc.function.name,
"input": tc.function.arguments,
}));
}
}
if content_blocks.is_empty() {
i += 1;
continue;
}
out.push(json!({"role": "assistant", "content": content_blocks}));
i += 1;
},
MessageRole::Tool => {
let mut tool_blocks: Vec<Value> = Vec::new();
while i < messages.len() && messages[i].role == MessageRole::Tool {
let t = &messages[i];
let tool_use_id = t.tool_call_id.clone().unwrap_or_default();
tool_blocks.push(json!({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": t.content,
}));
i += 1;
}
out.push(json!({"role": "user", "content": tool_blocks}));
},
}
}
(system, out)
}
pub struct AnthropicAdapter {
client: Client,
api_key: String,
base_url: String,
model_name: String,
capabilities: ModelCapabilities,
}
impl AnthropicAdapter {
pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
let client = Client::builder()
.pool_max_idle_per_host(10)
.pool_idle_timeout(Duration::from_secs(90))
.tcp_keepalive(Duration::from_secs(60))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| {
ModelError::Backend(BackendError::ConnectionFailed {
backend: "anthropic".to_string(),
url: base_url.clone(),
reason: e.to_string(),
})
})?;
let capabilities = ModelCapabilities {
supports_tools: true,
supports_vision: true,
supports_reasoning: ReasoningCapability::Levels(vec![
ReasoningLevel::None,
ReasoningLevel::Low,
ReasoningLevel::Medium,
ReasoningLevel::High,
ReasoningLevel::Max,
ReasoningLevel::XHigh,
]),
max_context_tokens: None,
};
Ok(Self {
client,
api_key,
base_url,
model_name,
capabilities,
})
}
fn build_request_body(&self, messages: &[ChatMessage], config: &ModelConfig) -> Value {
let (system_from_msgs, anthropic_messages) = convert_messages(messages);
let system = config.system_prompt.clone().or(system_from_msgs);
let mut body = json!({
"model": self.model_name,
"messages": anthropic_messages,
"max_tokens": if config.max_tokens > 0 { config.max_tokens } else { 4096 },
"stream": true,
});
if let Some(s) = system
&& !s.is_empty()
{
let mut blocks = vec![json!({
"type": "text",
"text": s,
"cache_control": {"type": "ephemeral"},
})];
if let Some(suffix) = config.dynamic_system_suffix.as_deref()
&& !suffix.is_empty()
{
blocks.push(json!({
"type": "text",
"text": suffix,
"cache_control": {"type": "ephemeral"},
}));
}
body["system"] = json!(blocks);
}
let temp = config.temperature.clamp(0.0, 1.0);
body["temperature"] = json!(temp);
let no_cloud_key = crate::ollama::get_cloud_api_key().is_none();
let filtered: Vec<&Value> = config
.tools
.iter()
.filter(|t| {
let name = t
.pointer("/function/name")
.and_then(|n| n.as_str())
.unwrap_or("");
!(no_cloud_key && (name == "web_search" || name == "web_fetch"))
})
.collect();
let mut anthropic_tools = to_anthropic_tools(&filtered);
if !anthropic_tools.is_empty() {
if let Some(last) = anthropic_tools.last_mut()
&& let Some(obj) = last.as_object_mut()
{
obj.insert("cache_control".to_string(), json!({"type": "ephemeral"}));
}
body["tools"] = json!(anthropic_tools);
}
let effective_reasoning = match &self.capabilities.supports_reasoning {
ReasoningCapability::Levels(supported) => {
nearest_effort(config.reasoning, supported).unwrap_or(ReasoningLevel::None)
},
_ => config.reasoning,
};
if let Some(effort) = adaptive_effort_for(effective_reasoning, &self.model_name) {
body["output_config"] = json!({"effort": effort});
}
match thinking_format_for(&self.model_name) {
ThinkingFormat::Adaptive => {
if effective_reasoning != ReasoningLevel::None {
let display = if config.hide_reasoning_trace {
"omitted"
} else {
"summarized"
};
body["thinking"] = json!({
"type": "adaptive",
"display": display,
});
}
},
ThinkingFormat::Legacy => {
if let Some(budget) = legacy_budget_for(
effective_reasoning,
body["max_tokens"].as_u64().unwrap_or(4096) as usize,
) {
body["thinking"] = json!({
"type": "enabled",
"budget_tokens": budget,
});
}
},
}
body
}
async fn send_chat(&self, body: &Value) -> Result<reqwest::Response> {
let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
crate::effect::retry_transient_http(|| async {
self.client
.post(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json")
.json(body)
.send()
.await
.map_err(|e| {
ModelError::Backend(BackendError::ConnectionFailed {
backend: "anthropic".to_string(),
url: url.clone(),
reason: e.to_string(),
})
})
})
.await
}
async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
if !response.status().is_success() {
return Err(http_error_from_response(response).await);
}
let json: AnthropicResponse =
response.json().await.map_err(|e| ModelError::ParseError {
message: format!("Failed to parse Anthropic response: {}", e),
raw: None,
})?;
let mut text_acc = String::new();
let mut thinking_acc = String::new();
let mut signature: Option<String> = None;
let mut tool_calls: Vec<ToolCall> = Vec::new();
for block in json.content {
match block {
ContentBlockOut::Text { text } => text_acc.push_str(&text),
ContentBlockOut::Thinking {
thinking,
signature: sig,
} => {
thinking_acc.push_str(&thinking);
if sig.is_some() {
signature = sig;
}
},
ContentBlockOut::ToolUse { id, name, input } => {
tool_calls.push(ToolCall {
id: Some(id),
function: FunctionCall {
name,
arguments: input,
},
});
},
ContentBlockOut::Other => {},
}
}
let prompt_tokens = json.usage.input_tokens.unwrap_or(0);
let completion_tokens = json.usage.output_tokens.unwrap_or(0);
let cache_creation = json.usage.cache_creation_input_tokens.unwrap_or(0);
let cache_read = json.usage.cache_read_input_tokens.unwrap_or(0);
let usage = TokenUsage::provider(
prompt_tokens,
completion_tokens,
prompt_tokens
.saturating_add(completion_tokens)
.saturating_add(cache_creation)
.saturating_add(cache_read),
)
.with_cache_creation(cache_creation)
.with_cached_input(cache_read);
Ok(ModelResponse {
content: text_acc,
usage: Some(usage),
model_name: self.model_name.clone(),
thinking: if thinking_acc.is_empty() {
None
} else {
Some(thinking_acc)
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
thinking_signature: signature,
})
}
async fn handle_stream(
&self,
response: reqwest::Response,
callback: StreamCallback,
hide_reasoning_trace: bool,
) -> Result<ModelResponse> {
if !response.status().is_success() {
return Err(http_error_from_response(response).await);
}
let mut stream = response.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
let mut text_acc = String::new();
let mut thinking_acc = String::new();
let mut signature_acc: Option<String> = None;
let mut tool_calls_done: Vec<ToolCall> = Vec::new();
let mut truncated = false;
let mut prompt_tokens: usize = 0;
let mut completion_tokens: usize = 0;
let mut cache_creation_tokens: usize = 0;
let mut cache_read_tokens: usize = 0;
let mut blocks: HashMap<usize, BlockAccumulator> = HashMap::new();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
buf.extend_from_slice(&chunk);
for payload in drain_sse_events(&mut buf) {
let parsed: Value = match serde_json::from_str(&payload) {
Ok(v) => v,
Err(e) => {
return Err(ModelError::ParseError {
message: format!("Failed to parse Anthropic stream chunk: {}", e),
raw: Some(payload),
});
},
};
let event_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
match event_type {
"message_start" => {
if let Some(input) = parsed
.pointer("/message/usage/input_tokens")
.and_then(|v| v.as_u64())
{
prompt_tokens = input as usize;
}
if let Some(cache_creation) = parsed
.pointer("/message/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64())
{
cache_creation_tokens = cache_creation as usize;
}
if let Some(cache_read) = parsed
.pointer("/message/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64())
{
cache_read_tokens = cache_read as usize;
}
},
"content_block_start" => {
let index =
parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let block = parsed.get("content_block");
let block_type = block
.and_then(|b| b.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
let acc = match block_type {
"text" => BlockAccumulator::Text(String::new()),
"thinking" => BlockAccumulator::Thinking {
content: String::new(),
signature: None,
},
"tool_use" => {
let id = block
.and_then(|b| b.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = block
.and_then(|b| b.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
BlockAccumulator::ToolUse {
id,
name,
input_buf: String::new(),
}
},
_ => BlockAccumulator::Other,
};
blocks.insert(index, acc);
},
"content_block_delta" => {
let index =
parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let delta = parsed.get("delta");
let delta_type = delta
.and_then(|d| d.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
let Some(acc) = blocks.get_mut(&index) else {
continue;
};
match (acc, delta_type) {
(BlockAccumulator::Text(buf_s), "text_delta") => {
let text = delta
.and_then(|d| d.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !text.is_empty() && !truncated {
callback(StreamEvent::Text(text.to_string()));
push_capped(buf_s, text, &mut truncated, MAX_RESPONSE_CHARS);
}
},
(
BlockAccumulator::Thinking { content, signature },
"thinking_delta",
) => {
let text = delta
.and_then(|d| d.get("thinking"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !text.is_empty() && !truncated {
if !hide_reasoning_trace {
callback(StreamEvent::Reasoning(ReasoningChunk {
text: text.to_string(),
signature: signature.clone(),
}));
}
push_capped(content, text, &mut truncated, MAX_RESPONSE_CHARS);
}
},
(BlockAccumulator::Thinking { signature, .. }, "signature_delta") => {
let sig = delta
.and_then(|d| d.get("signature"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !sig.is_empty() {
*signature = Some(sig.to_string());
}
},
(BlockAccumulator::ToolUse { input_buf, .. }, "input_json_delta") => {
let frag = delta
.and_then(|d| d.get("partial_json"))
.and_then(|v| v.as_str())
.unwrap_or("");
input_buf.push_str(frag);
},
_ => {
},
}
},
"content_block_stop" => {
let index =
parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
if let Some(acc) = blocks.remove(&index) {
match acc {
BlockAccumulator::Text(s) => {
text_acc.push_str(&s);
},
BlockAccumulator::Thinking { content, signature } => {
thinking_acc.push_str(&content);
if signature.is_some() {
signature_acc = signature;
}
},
BlockAccumulator::ToolUse {
id,
name,
input_buf,
} => {
let arguments: Value = if input_buf.is_empty() {
json!({})
} else {
match serde_json::from_str(&input_buf) {
Ok(v) => v,
Err(_) => Value::String(input_buf),
}
};
let tc = ToolCall {
id: if id.is_empty() { None } else { Some(id) },
function: FunctionCall { name, arguments },
};
callback(StreamEvent::ToolCall(tc.clone()));
tool_calls_done.push(tc);
},
BlockAccumulator::Other => {},
}
}
},
"message_delta" => {
if let Some(out) = parsed
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64())
{
completion_tokens = out as usize;
}
},
"message_stop" => {
break;
},
"error" => {
let err_type = parsed
.pointer("/error/type")
.and_then(|v| v.as_str())
.unwrap_or("api_error");
let err_msg = parsed
.pointer("/error/message")
.and_then(|v| v.as_str())
.unwrap_or("Anthropic stream error");
return Err(ModelError::Backend(BackendError::ProviderError {
provider: "anthropic".to_string(),
code: Some(err_type.to_string()),
message: err_msg.to_string(),
}));
},
"ping" | "" => {
},
_ => {
tracing::debug!("Anthropic: unknown event type: {}", event_type);
},
}
}
}
let total_tokens = prompt_tokens
.saturating_add(completion_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens);
Ok(ModelResponse {
content: text_acc,
usage: Some(
TokenUsage::provider(prompt_tokens, completion_tokens, total_tokens)
.with_cache_creation(cache_creation_tokens)
.with_cached_input(cache_read_tokens),
),
model_name: self.model_name.clone(),
thinking: if thinking_acc.is_empty() {
None
} else {
Some(thinking_acc)
},
tool_calls: if tool_calls_done.is_empty() {
None
} else {
Some(tool_calls_done)
},
thinking_signature: signature_acc,
})
}
}
#[async_trait]
impl Model for AnthropicAdapter {
fn name(&self) -> &str {
&self.model_name
}
fn capabilities(&self) -> &ModelCapabilities {
&self.capabilities
}
async fn list_models(&self) -> Result<Vec<String>> {
Err(ModelError::Unsupported {
feature: "list_models (anthropic)".to_string(),
})
}
async fn chat(
&self,
messages: &[ChatMessage],
config: &ModelConfig,
callback: Option<StreamCallback>,
) -> Result<ModelResponse> {
let mut body = self.build_request_body(messages, config);
let stream = callback.is_some();
if !stream {
body["stream"] = json!(false);
}
let response = self.send_chat(&body).await?;
if let Some(cb) = callback {
self.handle_stream(response, cb, config.hide_reasoning_trace)
.await
} else {
self.decode_non_streaming(response).await
}
}
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
content: Vec<ContentBlockOut>,
#[serde(default)]
usage: UsageOut,
}
#[derive(Debug, Default, Deserialize)]
struct UsageOut {
#[serde(default)]
input_tokens: Option<usize>,
#[serde(default)]
output_tokens: Option<usize>,
#[serde(default)]
cache_creation_input_tokens: Option<usize>,
#[serde(default)]
cache_read_input_tokens: Option<usize>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentBlockOut {
Text {
text: String,
},
Thinking {
thinking: String,
#[serde(default)]
signature: Option<String>,
},
ToolUse {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
input: Value,
},
#[serde(other)]
Other,
}
#[derive(Debug)]
enum BlockAccumulator {
Text(String),
Thinking {
content: String,
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
input_buf: String,
},
Other,
}
async fn http_error_from_response(response: reqwest::Response) -> ModelError {
let status = response.status().as_u16();
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if let Ok(parsed) = serde_json::from_str::<Value>(&body)
&& let (Some(err_type), Some(err_msg)) = (
parsed.pointer("/error/type").and_then(|v| v.as_str()),
parsed.pointer("/error/message").and_then(|v| v.as_str()),
)
{
if status == 400 && err_msg.to_lowercase().contains("thinking") {
return ModelError::Backend(BackendError::ProviderError {
provider: "anthropic".to_string(),
code: Some(err_type.to_string()),
message: format!(
"{} (thinking-block round-trip failed; this is a Mermaid bug — \
please open an issue with the conversation that triggered it)",
err_msg
),
});
}
return ModelError::Backend(BackendError::ProviderError {
provider: "anthropic".to_string(),
code: Some(err_type.to_string()),
message: err_msg.to_string(),
});
}
ModelError::Backend(BackendError::HttpError {
status,
message: body,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_adapter() -> AnthropicAdapter {
AnthropicAdapter::new(
"test-key".to_string(),
"claude-sonnet-4-6".to_string(),
"https://api.anthropic.com/v1".to_string(),
)
.expect("adapter constructs")
}
#[test]
fn thinking_format_dispatch() {
assert_eq!(
thinking_format_for("claude-opus-4-7"),
ThinkingFormat::Adaptive
);
assert_eq!(
thinking_format_for("claude-sonnet-4-6"),
ThinkingFormat::Adaptive
);
assert_eq!(
thinking_format_for("claude-opus-4-6"),
ThinkingFormat::Adaptive
);
assert_eq!(
thinking_format_for("claude-sonnet-4-5"),
ThinkingFormat::Legacy
);
assert_eq!(
thinking_format_for("claude-opus-4-5"),
ThinkingFormat::Legacy
);
assert_eq!(
thinking_format_for("claude-haiku-4-5"),
ThinkingFormat::Legacy
);
assert_eq!(
thinking_format_for("Claude-Opus-4-7-Special"),
ThinkingFormat::Adaptive
);
assert_eq!(
thinking_format_for("claude-future-99"),
ThinkingFormat::Legacy
);
}
#[test]
fn legacy_budget_clamps_to_max_tokens() {
assert_eq!(legacy_budget_for(ReasoningLevel::High, 8000), Some(6976));
assert_eq!(legacy_budget_for(ReasoningLevel::Low, 4096), Some(2048));
assert_eq!(legacy_budget_for(ReasoningLevel::None, 4096), None);
assert_eq!(legacy_budget_for(ReasoningLevel::Max, 64000), Some(32000));
assert_eq!(legacy_budget_for(ReasoningLevel::Max, 2000), Some(1024));
}
#[test]
fn adaptive_effort_per_level() {
let m = "claude-sonnet-4-6";
assert_eq!(adaptive_effort_for(ReasoningLevel::None, m), None);
assert_eq!(adaptive_effort_for(ReasoningLevel::Minimal, m), Some("low"));
assert_eq!(adaptive_effort_for(ReasoningLevel::Low, m), Some("low"));
assert_eq!(
adaptive_effort_for(ReasoningLevel::Medium, m),
Some("medium")
);
assert_eq!(adaptive_effort_for(ReasoningLevel::High, m), Some("high"));
assert_eq!(adaptive_effort_for(ReasoningLevel::Max, m), Some("max"));
}
#[test]
fn adaptive_effort_uses_xhigh_on_opus_4_7_for_xhigh() {
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-7"),
Some("xhigh")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, "claude-opus-4-7"),
Some("max")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-6"),
Some("high")
);
}
#[test]
fn adaptive_effort_gates_max_on_4_5_family() {
for m in ["claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5"] {
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, m),
Some("high"),
"model {} should snap Max → high (no max effort support)",
m
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, m),
Some("high"),
"model {} should snap XHigh → high",
m
);
}
}
#[test]
fn tool_translation_drops_function_wrapper() {
let openai_tool = json!({
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}
}
});
let translated = to_anthropic_tools(&[&openai_tool]);
assert_eq!(translated.len(), 1);
assert_eq!(translated[0]["name"], "read_file");
assert_eq!(translated[0]["description"], "Read a file");
assert_eq!(translated[0]["type"], "custom");
assert!(translated[0].get("function").is_none());
assert_eq!(
translated[0]["input_schema"]["properties"]["path"]["type"],
"string"
);
}
#[test]
fn tool_translation_handles_missing_description() {
let openai_tool = json!({
"type": "function",
"function": {
"name": "no_description_tool",
"parameters": {"type": "object", "properties": {}}
}
});
let translated = to_anthropic_tools(&[&openai_tool]);
assert_eq!(translated[0]["description"], "");
}
#[test]
fn convert_messages_extracts_system_only_first() {
let messages = vec![
ChatMessage::system("You are helpful."),
ChatMessage::user("Hello"),
ChatMessage::system("This second system message is dropped."),
];
let (system, msgs) = convert_messages(&messages);
assert_eq!(system.as_deref(), Some("You are helpful."));
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "user");
}
#[test]
fn convert_messages_merges_consecutive_tool_messages() {
let messages = vec![
ChatMessage::user("Read three files"),
{
let mut m = ChatMessage::assistant("I will read them.");
m.tool_calls = Some(vec![
ToolCall {
id: Some("c1".to_string()),
function: FunctionCall {
name: "read_file".into(),
arguments: json!({"path": "a.txt"}),
},
},
ToolCall {
id: Some("c2".to_string()),
function: FunctionCall {
name: "read_file".into(),
arguments: json!({"path": "b.txt"}),
},
},
ToolCall {
id: Some("c3".to_string()),
function: FunctionCall {
name: "read_file".into(),
arguments: json!({"path": "c.txt"}),
},
},
]);
m
},
ChatMessage::tool("c1", "read_file", "contents of a"),
ChatMessage::tool("c2", "read_file", "contents of b"),
ChatMessage::tool("c3", "read_file", "contents of c"),
ChatMessage::assistant("Done."),
];
let (_, msgs) = convert_messages(&messages);
assert_eq!(msgs.len(), 4);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[2]["role"], "user");
assert_eq!(msgs[3]["role"], "assistant");
let tool_results = msgs[2]["content"].as_array().expect("array");
assert_eq!(tool_results.len(), 3);
for (i, expected_id) in ["c1", "c2", "c3"].iter().enumerate() {
assert_eq!(tool_results[i]["type"], "tool_result");
assert_eq!(tool_results[i]["tool_use_id"], *expected_id);
}
}
#[test]
fn convert_messages_emits_thinking_block_with_signature() {
let mut msg = ChatMessage::assistant("Final answer.");
msg.thinking = Some("reasoning content".to_string());
msg.thinking_signature = Some("sig_xyz".to_string());
let messages = vec![ChatMessage::user("Q?"), msg];
let (_, msgs) = convert_messages(&messages);
let assistant_content = msgs[1]["content"].as_array().expect("array");
assert_eq!(assistant_content[0]["type"], "thinking");
assert_eq!(assistant_content[0]["thinking"], "reasoning content");
assert_eq!(assistant_content[0]["signature"], "sig_xyz");
assert_eq!(assistant_content[1]["type"], "text");
assert_eq!(assistant_content[1]["text"], "Final answer.");
}
#[test]
fn convert_messages_image_block_for_user_with_images() {
let msg = ChatMessage::user("What is this?").with_images(vec!["BASE64DATA".to_string()]);
let messages = vec![msg];
let (_, msgs) = convert_messages(&messages);
let content = msgs[0]["content"].as_array().expect("array");
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "What is this?");
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"], "BASE64DATA");
}
#[test]
fn build_request_body_includes_required_fields() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hello")];
let config = ModelConfig::default();
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["model"], "claude-sonnet-4-6");
assert_eq!(body["stream"], true);
assert!(body["max_tokens"].is_u64());
assert!(body["messages"].is_array());
}
#[test]
fn build_request_body_sets_system_field_not_message() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
system_prompt: Some("You are Mermaid.".to_string()),
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
let sys = body["system"].as_array().expect("system is array");
assert_eq!(sys.len(), 1);
assert_eq!(sys[0]["type"], "text");
assert_eq!(sys[0]["text"], "You are Mermaid.");
assert_eq!(sys[0]["cache_control"]["type"], "ephemeral");
let msgs = body["messages"].as_array().unwrap();
for m in msgs {
assert_ne!(m["role"], "system");
}
}
#[test]
fn build_request_body_emits_two_cache_blocks_when_suffix_present() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
system_prompt: Some("You are Mermaid.".to_string()),
dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
let sys = body["system"].as_array().expect("system is array");
assert_eq!(sys.len(), 2);
assert_eq!(sys[0]["text"], "You are Mermaid.");
assert_eq!(sys[0]["cache_control"]["type"], "ephemeral");
assert_eq!(sys[1]["text"], "Project rule: always snake_case.");
assert_eq!(sys[1]["cache_control"]["type"], "ephemeral");
}
#[test]
fn build_request_body_emits_single_block_when_suffix_absent() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
system_prompt: Some("You are Mermaid.".to_string()),
dynamic_system_suffix: None,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
let sys = body["system"].as_array().expect("system is array");
assert_eq!(sys.len(), 1);
assert_eq!(sys[0]["text"], "You are Mermaid.");
}
#[test]
fn build_request_body_uses_adaptive_for_sonnet_4_6() {
let adapter = test_adapter(); let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::High,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["display"], "summarized");
assert_eq!(body["output_config"]["effort"], "high");
assert!(body.get("effort").is_none(), "effort must NOT be top-level");
assert!(body["thinking"].get("budget_tokens").is_none());
}
#[test]
fn build_request_body_uses_legacy_for_sonnet_4_5() {
let adapter = AnthropicAdapter::new(
"k".to_string(),
"claude-sonnet-4-5".to_string(),
"https://api.anthropic.com/v1".to_string(),
)
.unwrap();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::Medium,
max_tokens: 8000,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 4096);
assert_eq!(body["output_config"]["effort"], "medium");
}
#[test]
fn build_request_body_omits_thinking_when_reasoning_is_none() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::None,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert!(body.get("thinking").is_none());
assert!(body.get("output_config").is_none());
assert!(body.get("effort").is_none(), "no top-level effort either");
}
#[test]
fn build_request_body_uses_xhigh_on_opus_4_7_for_xhigh() {
let adapter = AnthropicAdapter::new(
"k".to_string(),
"claude-opus-4-7".to_string(),
"https://api.anthropic.com/v1".to_string(),
)
.unwrap();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::XHigh,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["output_config"]["effort"], "xhigh");
assert_eq!(body["thinking"]["type"], "adaptive");
}
#[test]
fn build_request_body_uses_max_on_opus_4_6_for_max() {
let adapter = AnthropicAdapter::new(
"k".to_string(),
"claude-opus-4-6".to_string(),
"https://api.anthropic.com/v1".to_string(),
)
.unwrap();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::Max,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["output_config"]["effort"], "max");
}
#[test]
fn build_request_body_snaps_max_to_high_on_sonnet_4_5() {
let adapter = AnthropicAdapter::new(
"k".to_string(),
"claude-sonnet-4-5".to_string(),
"https://api.anthropic.com/v1".to_string(),
)
.unwrap();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::Max,
max_tokens: 8000,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(
body["output_config"]["effort"], "high",
"Sonnet 4.5 should snap Max → high (no max effort support)"
);
}
#[test]
fn build_request_body_sets_display_summarized_by_default() {
let adapter = test_adapter(); let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::Medium,
hide_reasoning_trace: false,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["thinking"]["display"], "summarized");
}
#[test]
fn build_request_body_sets_display_omitted_when_hide_reasoning_trace() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
reasoning: ReasoningLevel::Medium,
hide_reasoning_trace: true,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["thinking"]["display"], "omitted");
}
#[test]
fn build_request_body_clamps_temperature_to_anthropic_range() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
temperature: 1.5, ..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["temperature"].as_f64().unwrap(), 1.0);
}
#[test]
fn build_request_body_includes_tools_in_anthropic_shape() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
tools: vec![serde_json::json!({
"type": "function",
"function": {
"name": "test_tool",
"description": "a test tool",
"parameters": {"type": "object", "properties": {}}
}
})],
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
let tools = body["tools"].as_array().expect("tools array");
assert!(!tools.is_empty());
for tool in tools {
assert_eq!(tool["type"], "custom");
assert!(tool.get("function").is_none());
assert!(tool.get("name").is_some());
assert!(tool.get("input_schema").is_some());
}
}
#[test]
fn build_request_body_marks_only_last_tool_with_cache_control() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("Hi")];
let config = ModelConfig {
tools: vec![
serde_json::json!({
"type": "function",
"function": {
"name": "tool_a",
"description": "first",
"parameters": {"type": "object"}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "tool_b",
"description": "second",
"parameters": {"type": "object"}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "tool_c",
"description": "third",
"parameters": {"type": "object"}
}
}),
],
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
let tools = body["tools"].as_array().expect("tools array");
assert!(
tools.len() >= 2,
"need at least 2 tools to verify marker placement"
);
for tool in &tools[..tools.len() - 1] {
assert!(
tool.get("cache_control").is_none(),
"non-last tool should not carry cache_control: {:?}",
tool
);
}
let last = &tools[tools.len() - 1];
assert_eq!(
last["cache_control"]["type"], "ephemeral",
"last tool should carry the cache_control marker"
);
}
#[test]
fn build_request_body_handles_empty_tools_without_panicking() {
let result = to_anthropic_tools(&[]);
assert!(result.is_empty(), "empty input must produce empty output");
}
#[test]
fn capabilities_advertise_full_reasoning_levels_and_vision() {
let adapter = test_adapter();
let caps = adapter.capabilities();
assert!(caps.supports_tools);
assert!(caps.supports_vision);
match &caps.supports_reasoning {
ReasoningCapability::Levels(levels) => {
assert!(levels.contains(&ReasoningLevel::None));
assert!(levels.contains(&ReasoningLevel::Max));
},
other => panic!("expected Levels, got {:?}", other),
}
}
#[test]
fn name_returns_model_id() {
let adapter = test_adapter();
assert_eq!(adapter.name(), "claude-sonnet-4-6");
}
}