use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use serde_json::{Value, json};
use super::accumulator::{
ended_without_terminal, error_body, parse_tool_args, push_capped, push_tool_arg, slot_in_bounds,
};
use crate::constants::MAX_RESPONSE_CHARS;
use crate::models::ModelCapabilities;
use crate::models::adapters::driver::{Flow, Framing, StreamProtocol, drive_stream};
use crate::models::config::{ModelConfig, NativeCompaction};
use crate::models::error::{BackendError, ModelError, Result};
use crate::models::reasoning::{
ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
};
use crate::models::stream::{StreamEvent, StreamSink};
use crate::models::tool_call::{FunctionCall, ToolCall};
use crate::models::traits::Model;
use crate::utils::base64_image_media_type;
use super::ModelLimits;
use super::computer_toolset;
use super::learning::{Learning, Optional, ParamMemory, Rejections};
use super::native_tools::{self, Advertised};
use super::output_budget::{OutputBudgetInputs, OutputCapMode, resolve_output_budget};
use crate::models::types::{
ChatMessage, FinishReason, MessageAudience, MessageRole, ModelResponse, ProviderContinuation,
TokenUsage,
};
const ANTHROPIC_VERSION: &str = "2023-06-01";
const COMPACTION_BETA: &str = "compact-2026-01-12";
const COMPACTION_EDIT: &str = "compact_20260112";
const COMPACTION_PARAM: &str = "context_management";
fn map_anthropic_stop_reason(s: &str) -> FinishReason {
match s {
"end_turn" | "stop_sequence" => FinishReason::Stop,
"tool_use" => FinishReason::ToolUse,
"max_tokens" => FinishReason::Length,
"refusal" => FinishReason::ContentFilter,
other => FinishReason::Other(other.to_string()),
}
}
fn finalize_block(
acc: BlockAccumulator,
text_acc: &mut String,
thinking_acc: &mut String,
signature_acc: &mut Option<String>,
compaction_acc: &mut Option<Value>,
tool_calls: &mut ToolCalls,
out: &mut Vec<StreamEvent>,
) {
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,
toolset,
input_buf,
} => {
let arguments: Value = if input_buf.is_empty() {
json!({})
} else {
parse_tool_args(&name, input_buf)
};
let tc = tool_calls.push(
if id.is_empty() { None } else { Some(id) },
FunctionCall { name, arguments },
toolset.as_deref(),
);
out.push(StreamEvent::ToolCall(tc));
},
BlockAccumulator::Compaction(block) => {
out.push(StreamEvent::Status(COMPACTED_NOTICE.to_string()));
*compaction_acc = Some(block);
},
BlockAccumulator::Other => {},
}
}
fn context_management(native: Option<NativeCompaction>, rejected: &Rejections) -> Option<Value> {
let native = native.filter(|_| !rejected.contains(COMPACTION_PARAM))?;
Some(json!({
"edits": [{
"type": COMPACTION_EDIT,
"trigger": {"type": "input_tokens", "value": native.trigger_tokens},
}],
}))
}
const COMPACTED_NOTICE: &str =
"The provider compacted the earlier conversation to fit the context window";
fn continuation(
signature: Option<String>,
compaction: Option<Value>,
native_tool_calls: Vec<String>,
) -> Option<ProviderContinuation> {
if signature.is_none() && compaction.is_none() && native_tool_calls.is_empty() {
return None;
}
Some(ProviderContinuation::Anthropic {
signature: signature.unwrap_or_default(),
compaction,
native_tool_calls,
})
}
#[derive(Debug, Default)]
struct ToolCalls {
calls: Vec<ToolCall>,
native: Vec<String>,
}
impl ToolCalls {
fn push(
&mut self,
id: Option<String>,
mut function: FunctionCall,
toolset: Option<&str>,
) -> ToolCall {
let member = (toolset == Some(computer_toolset::TOOLSET_NAME))
.then(|| computer_toolset::canonicalize(&function.name, &function.arguments))
.flatten();
let native = match member {
Some(call) => {
function = call;
true
},
None => native_tools::canonicalize(&mut function),
};
if native && let Some(id) = &id {
self.native.push(id.clone());
}
let call = ToolCall { id, function };
self.calls.push(call.clone());
call
}
fn is_empty(&self) -> bool {
self.calls.is_empty()
}
fn into_parts(self) -> (Option<Vec<ToolCall>>, Vec<String>) {
let calls = (!self.calls.is_empty()).then_some(self.calls);
(calls, self.native)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThinkingFormat {
Adaptive,
Legacy,
}
impl ThinkingFormat {
const fn rejection(self) -> &'static str {
match self {
Self::Adaptive => "thinking:adaptive",
Self::Legacy => "thinking:enabled",
}
}
}
fn thinking_format_for(model: &str, rejected: &Rejections) -> Option<ThinkingFormat> {
use crate::models::catalog::ThinkingShape;
let order: &[ThinkingFormat] = match crate::models::catalog::lookup(model).thinking {
ThinkingShape::Unsupported => &[],
ThinkingShape::AnthropicBudget => &[ThinkingFormat::Legacy, ThinkingFormat::Adaptive],
_ => &[ThinkingFormat::Adaptive, ThinkingFormat::Legacy],
};
order
.iter()
.copied()
.find(|format| !rejected.contains(format.rejection()))
}
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,
};
if max_tokens <= 1024 {
return None;
}
let ceiling = max_tokens.saturating_sub(1024) as u32;
Some(proposed.min(ceiling).max(1024))
}
const ANTHROPIC_FALLBACK_MAX_OUTPUT_TOKENS: usize = 8_192;
fn estimate_prompt_tokens(messages: &[ChatMessage], system: Option<&str>) -> usize {
let chars =
messages.iter().map(|m| m.content.len()).sum::<usize>() + system.map_or(0, str::len);
chars / 4
}
fn adaptive_effort_for(
level: ReasoningLevel,
model: &str,
rejected: &Rejections,
) -> Option<&'static str> {
use crate::models::catalog::EffortCeiling;
let ceiling = crate::models::catalog::lookup(model)
.effort_ceiling
.unwrap_or(EffortCeiling::XHigh);
if ceiling == EffortCeiling::NoEffort || rejected.contains("effort") {
return None;
}
match level {
ReasoningLevel::None => None,
ReasoningLevel::Minimal | ReasoningLevel::Low => Some("low"),
ReasoningLevel::Medium => Some("medium"),
ReasoningLevel::High => Some("high"),
ReasoningLevel::XHigh => {
if ceiling >= EffortCeiling::XHigh && !rejected.contains("effort:xhigh") {
Some("xhigh")
} else {
Some("high")
}
},
ReasoningLevel::Max => {
if ceiling >= EffortCeiling::Max && !rejected.contains("effort:max") {
Some("max")
} else {
Some("high")
}
},
}
}
fn sent_optionals(body: &Value) -> Vec<Optional> {
let mut sent = Vec::new();
if body.get("temperature").is_some() {
sent.push(Optional::new(
"temperature",
"temperature",
&["temperature"],
));
}
if let Some(effort) = body
.pointer("/output_config/effort")
.and_then(Value::as_str)
{
let remember = match effort {
"xhigh" | "max" => format!("effort:{effort}"),
_ => "effort".to_string(),
};
sent.push(Optional::new(
&remember,
&format!("effort \"{effort}\""),
&["effort"],
));
}
if let Some(kind) = body.pointer("/thinking/type").and_then(Value::as_str) {
sent.push(
Optional::new(
&format!("thinking:{kind}"),
&format!("thinking type \"{kind}\""),
&["thinking", "budget_tokens"],
)
.unless(&["signature"]),
);
}
if body.get(COMPACTION_PARAM).is_some() {
sent.push(Optional::new(
COMPACTION_PARAM,
"server-side compaction",
&[COMPACTION_PARAM, "compact"],
));
}
let declared: Vec<&str> = body
.get("tools")
.and_then(Value::as_array)
.map(|tools| tools.iter().filter_map(|t| t["type"].as_str()).collect())
.unwrap_or_default();
if declared
.iter()
.any(|t| *t != "custom" && *t != computer_toolset::TOOLSET_TYPE)
{
sent.push(Optional::new(
native_tools::REJECTION,
"Anthropic's text editor and bash tools",
native_tools::REJECTION_NAMES,
));
}
if declared.contains(&computer_toolset::TOOLSET_TYPE) {
sent.push(Optional::new(
computer_toolset::REJECTION,
"Anthropic's computer toolset",
computer_toolset::REJECTION_NAMES,
));
}
sent
}
fn to_anthropic_tools(openai_tools: &[&Value], native: Advertised) -> Vec<Value> {
openai_tools
.iter()
.filter_map(|tool| {
let function = tool.get("function")?;
let name = function.get("name")?.as_str()?;
if native.replaces(name) {
return None;
}
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 advertised_native_tools(config: &ModelConfig, rejected: &Rejections) -> Advertised {
let names: Vec<&str> = config
.tools
.iter()
.filter_map(|t| t.pointer("/function/name").and_then(Value::as_str))
.collect();
let mut advertised = Advertised::resolve(config.native_tools, &names);
if rejected.contains(native_tools::REJECTION) {
advertised = advertised.without_editor_and_bash();
}
if rejected.contains(computer_toolset::REJECTION) {
advertised = advertised.without_computer();
}
advertised
}
fn request_tools(config: &ModelConfig, native: Advertised) -> Vec<Value> {
let registered: Vec<&Value> = config.tools.iter().collect();
let mut tools = to_anthropic_tools(®istered, native);
tools.extend(native.declarations());
if let Some(last) = tools.last_mut()
&& let Some(obj) = last.as_object_mut()
{
obj.insert("cache_control".to_string(), json!({"type": "ephemeral"}));
}
tools
}
fn push_system_reminder(out: &mut Vec<Value>, text: &str) {
out.push(json!({
"role": "user",
"content": [{
"type": "text",
"text": format!("<system-reminder>\n{text}\n</system-reminder>"),
}],
}));
}
fn content_blocks(content: &Value) -> Vec<Value> {
match content {
Value::Array(blocks) => blocks.clone(),
Value::String(text) if !text.is_empty() => {
vec![json!({"type": "text", "text": text})]
},
_ => Vec::new(),
}
}
fn coalesce_consecutive_roles(msgs: Vec<Value>) -> Vec<Value> {
let mut out: Vec<Value> = Vec::with_capacity(msgs.len());
for msg in msgs {
let same_role = out.last().is_some_and(|prev| prev["role"] == msg["role"]);
let Some(prev) = out.last_mut().filter(|_| same_role) else {
out.push(msg);
continue;
};
let incoming = content_blocks(&msg["content"]);
if incoming.is_empty() {
continue;
}
let mut blocks = content_blocks(&prev["content"]);
blocks.extend(incoming);
let lead: &[&str] = if msg["role"] == "user" {
&["tool_result"]
} else {
&["compaction", "thinking"]
};
blocks.sort_by_key(|b| {
lead.iter()
.position(|kind| b["type"] == *kind)
.unwrap_or(lead.len())
});
prev["content"] = Value::Array(blocks);
}
out
}
fn convert_messages(messages: &[ChatMessage], native: Advertised) -> (Option<String>, Vec<Value>) {
let mut system: Option<String> = None;
let mut out: Vec<Value> = Vec::new();
let mut member_calls: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
match msg.role {
MessageRole::System
if msg.kind.audience() == MessageAudience::ModelDirected
&& !msg.content.is_empty() =>
{
push_system_reminder(&mut out, &msg.content);
i += 1;
},
MessageRole::System => {
if system.is_none() {
system = Some(msg.content.clone());
}
i += 1;
},
MessageRole::User => {
out.push(json!({"role": "user", "content": user_content(msg)}));
i += 1;
},
MessageRole::Assistant => {
let content_blocks = assistant_content_blocks(msg, native);
member_calls.extend(
content_blocks
.iter()
.filter(|b| b.get("toolset_name").is_some())
.filter_map(|b| b["id"].as_str().map(str::to_string)),
);
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();
let mut block = json!({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": tool_result_content(t),
});
if member_calls.contains(&tool_use_id) {
block["toolset_name"] = json!(computer_toolset::TOOLSET_NAME);
}
tool_blocks.push(block);
i += 1;
}
out.push(json!({"role": "user", "content": tool_blocks}));
},
}
}
(system, coalesce_consecutive_roles(out))
}
fn user_content(msg: &ChatMessage) -> Value {
let mut content_blocks: Vec<Value> = Vec::new();
if !msg.content.is_empty() {
content_blocks.push(json!({
"type": "text",
"text": msg.content,
}));
}
content_blocks.extend(msg.images.iter().flatten().map(|data| image_block(data)));
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)
}
}
fn image_block(data: &str) -> Value {
json!({
"type": "image",
"source": {
"type": "base64",
"media_type": base64_image_media_type(data),
"data": data,
},
})
}
fn tool_result_content(msg: &ChatMessage) -> Value {
let Some(images) = msg.images.as_ref().filter(|images| !images.is_empty()) else {
return json!(msg.content);
};
let mut blocks = Vec::with_capacity(images.len() + 1);
if !msg.content.is_empty() {
blocks.push(json!({"type": "text", "text": msg.content}));
}
blocks.extend(images.iter().map(|data| image_block(data)));
json!(blocks)
}
fn assistant_content_blocks(msg: &ChatMessage, native: Advertised) -> Vec<Value> {
let mut content_blocks: Vec<Value> = Vec::new();
if let Some(block) = msg
.provider_continuation
.as_ref()
.and_then(ProviderContinuation::anthropic_compaction)
{
content_blocks.push(block.clone());
}
if let (Some(thinking), Some(sig)) = (
&msg.thinking,
msg.provider_continuation
.as_ref()
.and_then(ProviderContinuation::anthropic_signature),
) && !thinking.is_empty()
&& !sig.is_empty()
{
content_blocks.push(json!({
"type": "thinking",
"thinking": thinking,
"signature": sig,
}));
} else if msg.thinking.as_deref().is_some_and(|t| !t.is_empty()) {
tracing::debug!("dropping assistant thinking block that lacks a signature (would 400)",);
}
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 {
let id = tc.id.clone().unwrap_or_default();
let made_natively = msg
.provider_continuation
.as_ref()
.is_some_and(|c| c.is_anthropic_native_call(&id));
if let Some((member, input)) = made_natively
.then(|| native.to_computer_member(&tc.function))
.flatten()
{
content_blocks.push(json!({
"type": "tool_use",
"id": id,
"name": member,
"input": input,
"toolset_name": computer_toolset::TOOLSET_NAME,
}));
continue;
}
let (name, input) = made_natively
.then(|| native.to_native(&tc.function))
.flatten()
.map_or_else(
|| (tc.function.name.clone(), tc.function.arguments.clone()),
|(name, input)| (name.to_string(), input),
);
content_blocks.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input,
}));
}
}
content_blocks
}
pub struct AnthropicAdapter {
client: Client,
api_key: String,
base_url: String,
model_name: String,
capabilities: ModelCapabilities,
memory: ParamMemory,
}
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::advertised(
true,
ReasoningCapability::Levels(vec![
ReasoningLevel::None,
ReasoningLevel::Low,
ReasoningLevel::Medium,
ReasoningLevel::High,
ReasoningLevel::Max,
ReasoningLevel::XHigh,
]),
);
Ok(Self {
client,
api_key,
base_url,
model_name,
capabilities,
memory: ParamMemory::default(),
})
}
#[must_use]
pub const fn param_memory(&self) -> &ParamMemory {
&self.memory
}
#[must_use]
pub fn compacts_natively(&self) -> bool {
!self.memory.snapshot().contains(COMPACTION_PARAM)
}
#[cfg(test)]
fn build_request_body(&self, messages: &[ChatMessage], config: &ModelConfig) -> Value {
self.build_request_body_with(messages, config, &self.memory.snapshot())
}
fn build_request_body_with(
&self,
messages: &[ChatMessage],
config: &ModelConfig,
rejected: &Rejections,
) -> Value {
let native = advertised_native_tools(config, rejected);
let (system_from_msgs, anthropic_messages) = convert_messages(messages, native);
let system = config.system_prompt.clone().or(system_from_msgs);
let max_tokens = resolve_output_budget(
&OutputBudgetInputs {
requested_cap: config.max_tokens,
window: config.resolved_context_window,
prompt_estimate: estimate_prompt_tokens(
ChatMessage::since_provider_compaction(messages),
system.as_deref(),
),
provider_max_output: Some(
config
.resolved_max_output
.unwrap_or(ANTHROPIC_FALLBACK_MAX_OUTPUT_TOKENS),
),
margin: 1_024,
floor: 1,
},
OutputCapMode::Required,
)
.expect("Required mode always resolves a concrete max_tokens");
let mut body = json!({
"model": self.model_name,
"messages": anthropic_messages,
"max_tokens": max_tokens,
"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);
}
if crate::models::catalog::lookup(&self.model_name).supports_temperature
&& !rejected.contains("temperature")
{
let temp = config.temperature.clamp(0.0, 1.0);
body["temperature"] = json!(temp);
}
let tools = request_tools(config, native);
if !tools.is_empty() {
body["tools"] = json!(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, rejected) {
body["output_config"] = json!({"effort": effort});
}
if let Some(schema) = &config.output_schema {
body["output_config"]["format"] = json!({
"type": "json_schema",
"schema": schema,
});
}
if let Some(edits) = context_management(config.native_compaction, rejected) {
body[COMPACTION_PARAM] = edits;
}
match thinking_format_for(&self.model_name, rejected) {
Some(ThinkingFormat::Adaptive) if effective_reasoning != ReasoningLevel::None => {
body["thinking"] = json!({
"type": "adaptive",
"display": "summarized",
});
},
Some(ThinkingFormat::Adaptive) => {},
Some(ThinkingFormat::Legacy) => {
if let Some(budget) = legacy_budget_for(effective_reasoning, max_tokens) {
body["thinking"] = json!({
"type": "enabled",
"budget_tokens": budget,
});
}
},
None => {},
}
body
}
async fn send_chat(&self, body: &Value) -> Result<reqwest::Response> {
let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
let beta = body.get(COMPACTION_PARAM).map(|_| COMPACTION_BETA);
crate::models::retry::retry_transient_http(|| async {
let mut request = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json");
if let Some(beta) = beta {
request = request.header("anthropic-beta", beta);
}
request.json(body).send().await.map_err(|e| {
ModelError::Backend(BackendError::ConnectionFailed {
backend: "anthropic".to_string(),
url: url.clone(),
reason: e.to_string(),
})
})
})
.await
}
pub async fn fetch_model_limits(&self) -> Result<ModelLimits> {
let url = format!(
"{}/models/{}",
self.base_url.trim_end_matches('/'),
self.model_name
);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.send()
.await
.map_err(|e| {
ModelError::Backend(BackendError::ConnectionFailed {
backend: "anthropic".to_string(),
url: url.clone(),
reason: e.to_string(),
})
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(ModelLimits::default());
}
if !response.status().is_success() {
return Err(http_error_from_response(response).await);
}
let info: AnthropicModelInfo =
response.json().await.map_err(|e| ModelError::ParseError {
message: format!("Failed to parse Anthropic model info: {e}"),
raw: None,
})?;
Ok(info.into())
}
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 compaction: Option<Value> = None;
let mut tool_calls = ToolCalls::default();
for raw in json.content {
if raw.get("type").and_then(Value::as_str) == Some("compaction") {
compaction = Some(raw);
continue;
}
let block = serde_json::from_value(raw).unwrap_or(ContentBlockOut::Other);
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,
toolset_name,
} => {
tool_calls.push(
Some(id),
FunctionCall {
name,
arguments: input,
},
toolset_name.as_deref(),
);
},
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)
.with_cache_creation(cache_creation)
.with_cached_input(cache_read);
let stop_reason = json.stop_reason.as_deref().map(map_anthropic_stop_reason);
if text_acc.is_empty()
&& tool_calls.is_empty()
&& stop_reason == Some(FinishReason::ContentFilter)
{
return Err(ModelError::Backend(BackendError::ProviderError {
provider: "anthropic".to_string(),
code: Some("refusal".to_string()),
message: "Anthropic returned no content (refusal / content filter)".to_string(),
debug: crate::models::error::ResponseDebugContext::default(),
}));
}
let (tool_calls, native_calls) = tool_calls.into_parts();
Ok(ModelResponse {
content: text_acc,
usage: Some(usage),
model_name: self.model_name.clone(),
stop_reason,
thinking: if thinking_acc.is_empty() {
None
} else {
Some(thinking_acc)
},
tool_calls,
provider_continuation: continuation(signature, compaction, native_calls),
})
}
async fn handle_stream(
&self,
response: reqwest::Response,
sink: Option<&StreamSink>,
) -> Result<ModelResponse> {
if !response.status().is_success() {
return Err(http_error_from_response(response).await);
}
drive_stream(
response.bytes_stream(),
AnthropicStream::new(self.model_name.clone()),
sink,
)
.await
}
}
pub(crate) struct AnthropicStream {
model_name: String,
text_acc: String,
thinking_acc: String,
signature_acc: Option<String>,
compaction_acc: Option<Value>,
tool_calls_done: ToolCalls,
text_truncated: bool,
thinking_truncated: bool,
saw_usage: bool,
prompt_tokens: usize,
completion_tokens: usize,
cache_creation_tokens: usize,
cache_read_tokens: usize,
stop_reason: Option<FinishReason>,
saw_message_stop: bool,
blocks: HashMap<usize, BlockAccumulator>,
}
impl AnthropicStream {
pub(crate) fn new(model_name: String) -> Self {
Self {
model_name,
text_acc: String::new(),
thinking_acc: String::new(),
signature_acc: None,
compaction_acc: None,
tool_calls_done: ToolCalls::default(),
text_truncated: false,
thinking_truncated: false,
saw_usage: false,
prompt_tokens: 0,
completion_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
stop_reason: None,
saw_message_stop: false,
blocks: HashMap::new(),
}
}
}
impl AnthropicStream {
fn on_message_start(&mut self, parsed: &Value) {
if let Some(input) = parsed
.pointer("/message/usage/input_tokens")
.and_then(|v| v.as_u64())
{
self.prompt_tokens = input as usize;
self.saw_usage = true;
}
if let Some(cache_creation) = parsed
.pointer("/message/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64())
{
self.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())
{
self.cache_read_tokens = cache_read as usize;
}
}
fn on_block_delta(&mut self, index: usize, delta: Option<&Value>, out: &mut Vec<StreamEvent>) {
let delta_type = delta
.and_then(|d| d.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
let Some(acc) = self.blocks.get_mut(&index) else {
return;
};
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() && !self.text_truncated {
out.push(StreamEvent::Text(text.to_string()));
push_capped(buf_s, text, &mut self.text_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() && !self.thinking_truncated {
out.push(StreamEvent::Reasoning(ReasoningChunk {
text: text.to_string(),
signature: signature.clone(),
}));
push_capped(
content,
text,
&mut self.thinking_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::Compaction(block), kind) if kind.ends_with("_delta") => {
append_string_fields(block, delta);
},
(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("");
push_tool_arg(input_buf, frag);
},
_ => {
},
}
}
}
fn stream_error(parsed: &Value) -> ModelError {
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");
ModelError::Backend(BackendError::ProviderError {
provider: "anthropic".to_string(),
code: Some(err_type.to_string()),
message: err_msg.to_string(),
debug: crate::models::error::ResponseDebugContext::default(),
})
}
fn block_accumulator_for(block: Option<&Value>) -> BlockAccumulator {
let block_type = block
.and_then(|b| b.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
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();
let toolset = block
.and_then(|b| b.get("toolset_name"))
.and_then(|v| v.as_str())
.map(str::to_string);
BlockAccumulator::ToolUse {
id,
name,
toolset,
input_buf: String::new(),
}
},
"compaction" => BlockAccumulator::Compaction(block.cloned().unwrap_or_default()),
_ => BlockAccumulator::Other,
}
}
fn append_string_fields(block: &mut Value, delta: Option<&Value>) {
let (Some(block), Some(delta)) = (block.as_object_mut(), delta.and_then(Value::as_object))
else {
return;
};
for (key, value) in delta {
let Some(part) = value.as_str().filter(|_| key != "type") else {
continue;
};
match block.get_mut(key) {
Some(Value::String(existing)) => existing.push_str(part),
_ => {
block.insert(key.clone(), Value::String(part.to_string()));
},
}
}
}
impl StreamProtocol for AnthropicStream {
const FRAMING: Framing = Framing::Sse;
fn on_frame(&mut self, frame: &str, out: &mut Vec<StreamEvent>) -> Result<Flow> {
let parsed: Value = match serde_json::from_str(frame) {
Ok(v) => v,
Err(e) => {
return Err(ModelError::ParseError {
message: format!("Failed to parse Anthropic stream chunk: {e}"),
raw: Some(frame.to_string()),
});
},
};
let event_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
match event_type {
"message_start" => self.on_message_start(&parsed),
"content_block_start" => {
let index = parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let acc = block_accumulator_for(parsed.get("content_block"));
if !slot_in_bounds(index) {
tracing::warn!(
index,
"anthropic stream opened a content block past the bound; ignoring it"
);
return Ok(Flow::Continue);
}
self.blocks.insert(index, acc);
},
"content_block_delta" => {
let index = parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
self.on_block_delta(index, parsed.get("delta"), out);
},
"content_block_stop" => {
let index = parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
if let Some(acc) = self.blocks.remove(&index) {
finalize_block(
acc,
&mut self.text_acc,
&mut self.thinking_acc,
&mut self.signature_acc,
&mut self.compaction_acc,
&mut self.tool_calls_done,
out,
);
}
},
"message_delta" => {
if let Some(tokens) = parsed
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64())
{
self.completion_tokens = tokens as usize;
self.saw_usage = true;
}
if let Some(sr) = parsed
.pointer("/delta/stop_reason")
.and_then(|v| v.as_str())
{
self.stop_reason = Some(map_anthropic_stop_reason(sr));
}
},
"message_stop" => {
self.saw_message_stop = true;
return Ok(Flow::Stop);
},
"error" => return Err(stream_error(&parsed)),
"ping" | "" => {
},
_ => {
tracing::debug!("Anthropic: unknown event type: {}", event_type);
},
}
Ok(Flow::Continue)
}
fn finish(mut self, out: &mut Vec<StreamEvent>) -> Result<ModelResponse> {
if !self.saw_message_stop && ended_without_terminal(self.stop_reason.as_ref()) {
return Err(ModelError::StreamError(
"Anthropic stream closed before any terminal frame (message_stop / \
message_delta stop_reason); the connection was likely dropped \
mid-response"
.to_string(),
));
}
if !self.blocks.is_empty() {
tracing::warn!(
open_blocks = self.blocks.len(),
"Anthropic stream ended without message_stop; draining open blocks"
);
let mut remaining: Vec<(usize, BlockAccumulator)> =
std::mem::take(&mut self.blocks).into_iter().collect();
remaining.sort_by_key(|(idx, _)| *idx);
for (_idx, acc) in remaining {
finalize_block(
acc,
&mut self.text_acc,
&mut self.thinking_acc,
&mut self.signature_acc,
&mut self.compaction_acc,
&mut self.tool_calls_done,
out,
);
}
}
if self.text_acc.is_empty()
&& self.tool_calls_done.is_empty()
&& self.stop_reason == Some(FinishReason::ContentFilter)
{
return Err(ModelError::Backend(BackendError::ProviderError {
provider: "anthropic".to_string(),
code: Some("refusal".to_string()),
message: "Anthropic returned no content (refusal / content filter)".to_string(),
debug: crate::models::error::ResponseDebugContext::default(),
}));
}
let (tool_calls, native_calls) = self.tool_calls_done.into_parts();
Ok(ModelResponse {
content: self.text_acc,
usage: self.saw_usage.then(|| {
TokenUsage::provider(self.prompt_tokens, self.completion_tokens)
.with_cache_creation(self.cache_creation_tokens)
.with_cached_input(self.cache_read_tokens)
}),
model_name: self.model_name,
stop_reason: self.stop_reason,
thinking: if self.thinking_acc.is_empty() {
None
} else {
Some(self.thinking_acc)
},
tool_calls,
provider_continuation: continuation(
self.signature_acc,
self.compaction_acc,
native_calls,
),
})
}
}
#[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,
sink: Option<StreamSink>,
) -> Result<ModelResponse> {
let mut learning = Learning::start(&self.memory, &self.model_name, sink.as_ref());
let response = loop {
let mut body = self.build_request_body_with(messages, config, learning.rejections());
if sink.is_none() {
body["stream"] = json!(false);
}
let response = self.send_chat(&body).await?;
if !learning.is_retryable(&response) {
break response;
}
let err = http_error_from_response(response).await;
learning.retry_or_fail(err, &sent_optionals(&body)).await?;
};
learning.settle(&response);
if let Some(sink) = sink {
self.handle_stream(response, Some(&sink)).await
} else {
self.decode_non_streaming(response).await
}
}
}
#[derive(Debug, Default, Deserialize)]
struct AnthropicModelInfo {
#[serde(default)]
max_input_tokens: Option<usize>,
#[serde(default)]
max_tokens: Option<usize>,
}
impl From<AnthropicModelInfo> for ModelLimits {
fn from(info: AnthropicModelInfo) -> Self {
Self {
max_context_tokens: info.max_input_tokens,
max_output_tokens: info.max_tokens,
}
}
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
content: Vec<Value>,
#[serde(default)]
usage: UsageOut,
#[serde(default)]
stop_reason: Option<String>,
}
#[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 {
id: String,
name: String,
input: Value,
#[serde(default)]
toolset_name: Option<String>,
},
#[serde(other)]
Other,
}
#[derive(Debug)]
enum BlockAccumulator {
Text(String),
Thinking {
content: String,
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
toolset: Option<String>,
input_buf: String,
},
Compaction(Value),
Other,
}
async fn http_error_from_response(response: reqwest::Response) -> ModelError {
let status = response.status().as_u16();
let debug = crate::models::error::ResponseDebugContext::from_headers(response.headers());
let body = error_body(response, "Unknown error").await;
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!(
"{err_msg} (thinking-block round-trip failed; this is a Mermaid bug — \
please open an issue with the conversation that triggered it)"
),
debug: debug.clone(),
});
}
return ModelError::Backend(BackendError::ProviderError {
provider: "anthropic".to_string(),
code: Some(err_type.to_string()),
message: err_msg.to_string(),
debug: debug.clone(),
});
}
ModelError::Backend(BackendError::HttpError {
status,
message: body,
debug,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn has_thinking_block(msgs: &[serde_json::Value]) -> bool {
msgs.iter().any(|msg| {
msg.get("content")
.and_then(|c| c.as_array())
.map(|blocks| {
blocks
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("thinking"))
})
.unwrap_or(false)
})
}
#[test]
fn model_info_parses_documented_limit_fields() {
let body = r#"{
"id": "claude-sonnet-4-6",
"type": "model",
"display_name": "Claude Sonnet 4.6",
"created_at": "2026-02-01T00:00:00Z",
"max_input_tokens": 1000000,
"max_tokens": 128000
}"#;
let info: AnthropicModelInfo = serde_json::from_str(body).expect("parse");
let limits: ModelLimits = info.into();
assert_eq!(limits.max_context_tokens, Some(1_000_000));
assert_eq!(limits.max_output_tokens, Some(128_000));
}
#[test]
fn model_info_missing_limit_fields_degrade_to_none() {
let body = r#"{"id": "claude-sonnet-4-6", "type": "model"}"#;
let info: AnthropicModelInfo = serde_json::from_str(body).expect("parse");
let limits: ModelLimits = info.into();
assert_eq!(limits.max_context_tokens, None);
assert_eq!(limits.max_output_tokens, None);
}
#[test]
fn maps_anthropic_stop_reasons() {
assert_eq!(map_anthropic_stop_reason("end_turn"), FinishReason::Stop);
assert_eq!(
map_anthropic_stop_reason("max_tokens"),
FinishReason::Length
);
assert_eq!(map_anthropic_stop_reason("tool_use"), FinishReason::ToolUse);
assert_eq!(
map_anthropic_stop_reason("refusal"),
FinishReason::ContentFilter
);
}
#[test]
fn stream_closed_abnormally_distinguishes_drop_from_completion() {
let closed_abnormally = |saw_message_stop: bool, stop_reason: Option<&FinishReason>| {
!saw_message_stop && ended_without_terminal(stop_reason)
};
assert!(closed_abnormally(false, None));
assert!(!closed_abnormally(true, Some(&FinishReason::Stop)));
assert!(!closed_abnormally(false, Some(&FinishReason::Stop)));
assert!(!closed_abnormally(false, Some(&FinishReason::Length)));
assert!(!closed_abnormally(true, None));
}
#[test]
fn finalize_block_recovers_tool_use() {
let mut text = String::new();
let mut thinking = String::new();
let mut sig = None;
let mut compaction = None;
let mut tools = ToolCalls::default();
let mut events = Vec::new();
finalize_block(
BlockAccumulator::ToolUse {
id: "tu_1".to_string(),
name: "read_file".to_string(),
toolset: None,
input_buf: r#"{"path":"a.txt"}"#.to_string(),
},
&mut text,
&mut thinking,
&mut sig,
&mut compaction,
&mut tools,
&mut events,
);
assert_eq!(tools.calls.len(), 1);
assert_eq!(tools.calls[0].function.name, "read_file");
assert!(matches!(events.as_slice(), [StreamEvent::ToolCall(_)]));
}
#[test]
fn thinking_block_requires_signature() {
let mut unsigned = ChatMessage::assistant("answer");
unsigned.thinking = Some("private reasoning".to_string());
let (_sys, msgs) = convert_messages(&[unsigned], Advertised::default());
assert!(
!has_thinking_block(&msgs),
"unsigned thinking must be dropped"
);
let mut signed = ChatMessage::assistant("answer")
.with_provider_continuation(ProviderContinuation::anthropic("sig123".to_string()));
signed.thinking = Some("private reasoning".to_string());
let (_sys, msgs) = convert_messages(&[signed], Advertised::default());
assert!(has_thinking_block(&msgs), "signed thinking must be present");
}
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", &Rejections::new()),
Some(ThinkingFormat::Adaptive)
);
assert_eq!(
thinking_format_for("claude-sonnet-4-6", &Rejections::new()),
Some(ThinkingFormat::Adaptive)
);
assert_eq!(
thinking_format_for("claude-opus-4-6", &Rejections::new()),
Some(ThinkingFormat::Adaptive)
);
assert_eq!(
thinking_format_for("claude-opus-4-8", &Rejections::new()),
Some(ThinkingFormat::Adaptive)
);
assert_eq!(
thinking_format_for("claude-fable-5", &Rejections::new()),
Some(ThinkingFormat::Adaptive)
);
assert_eq!(
thinking_format_for("claude-sonnet-4-5", &Rejections::new()),
Some(ThinkingFormat::Legacy)
);
assert_eq!(
thinking_format_for("claude-opus-4-5", &Rejections::new()),
Some(ThinkingFormat::Legacy)
);
assert_eq!(
thinking_format_for("claude-haiku-4-5", &Rejections::new()),
Some(ThinkingFormat::Legacy)
);
assert_eq!(
thinking_format_for("Claude-Opus-4-7-Special", &Rejections::new()),
Some(ThinkingFormat::Adaptive)
);
assert_eq!(
thinking_format_for("claude-future-99", &Rejections::new()),
Some(ThinkingFormat::Adaptive)
);
assert_eq!(thinking_format_for("claude-2.1", &Rejections::new()), None);
}
#[test]
fn thinking_format_steps_past_what_the_provider_rejected() {
let adaptive_rejected = Rejections::from(["thinking:adaptive"]);
assert_eq!(
thinking_format_for("claude-future-99", &adaptive_rejected),
Some(ThinkingFormat::Legacy)
);
let legacy_rejected = Rejections::from(["thinking:enabled"]);
assert_eq!(
thinking_format_for("claude-sonnet-4-5", &legacy_rejected),
Some(ThinkingFormat::Adaptive)
);
let both = Rejections::from(["thinking:adaptive", "thinking:enabled"]);
assert_eq!(thinking_format_for("claude-future-99", &both), None);
}
#[test]
fn unknown_model_gets_the_effort_it_asked_for_until_rejected() {
let none = Rejections::new();
let m = "claude-future-99";
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, m, &none),
Some("xhigh")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, m, &none),
Some("max")
);
let no_xhigh = Rejections::from(["effort:xhigh"]);
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, m, &no_xhigh),
Some("high")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, m, &no_xhigh),
Some("max")
);
let no_max = Rejections::from(["effort:max"]);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, m, &no_max),
Some("high")
);
let no_effort = Rejections::from(["effort"]);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Medium, m, &no_effort),
None
);
}
#[test]
fn sent_optionals_reads_the_body_back() {
let body = json!({
"temperature": 0.7,
"output_config": {"effort": "xhigh"},
"thinking": {"type": "adaptive"},
});
let sent = sent_optionals(&body);
let remembered: Vec<&str> = sent.iter().map(|o| o.remember.as_str()).collect();
assert_eq!(
remembered,
["temperature", "effort:xhigh", "thinking:adaptive"]
);
let base = sent_optionals(&json!({"output_config": {"effort": "medium"}}));
assert_eq!(base[0].remember, "effort");
assert!(sent_optionals(&json!({})).is_empty());
}
#[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));
assert_eq!(legacy_budget_for(ReasoningLevel::High, 1024), None);
assert_eq!(legacy_budget_for(ReasoningLevel::Max, 512), None);
let b = legacy_budget_for(ReasoningLevel::High, 2048).expect("fits");
assert!(b < 2048, "budget {b} must be < max_tokens");
}
#[test]
fn adaptive_effort_per_level() {
let m = "claude-sonnet-4-6";
assert_eq!(
adaptive_effort_for(ReasoningLevel::None, m, &Rejections::new()),
None
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Minimal, m, &Rejections::new()),
Some("low")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Low, m, &Rejections::new()),
Some("low")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Medium, m, &Rejections::new()),
Some("medium")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::High, m, &Rejections::new()),
Some("high")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, m, &Rejections::new()),
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", &Rejections::new()),
Some("xhigh")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, "claude-opus-4-7", &Rejections::new()),
Some("max")
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-6", &Rejections::new()),
Some("high")
);
}
#[test]
fn adaptive_effort_gates_max_on_4_5_family() {
for m in ["claude-sonnet-4-5", "claude-haiku-4-5"] {
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, m, &Rejections::new()),
None,
"model {m} does not support the effort parameter at all"
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, m, &Rejections::new()),
None,
"model {m} does not support the effort parameter at all"
);
}
assert_eq!(
adaptive_effort_for(ReasoningLevel::Max, "claude-opus-4-5", &Rejections::new()),
Some("high"),
"Opus 4.5 should snap Max → high (no max effort support)"
);
assert_eq!(
adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-5", &Rejections::new()),
Some("high"),
"Opus 4.5 should snap XHigh → high"
);
}
#[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], Advertised::default());
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], Advertised::default());
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, Advertised::default());
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, Advertised::default());
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.provider_continuation = Some(ProviderContinuation::anthropic("sig_xyz".to_string()));
let messages = vec![ChatMessage::user("Q?"), msg];
let (_, msgs) = convert_messages(&messages, Advertised::default());
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, Advertised::default());
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 a_tool_result_carries_the_images_its_tool_returned() {
use super::super::tool_images::{JPEG_B64, tool_loop_with_image};
let (_, msgs) = convert_messages(&tool_loop_with_image(), Advertised::default());
let results = msgs[2]["content"].as_array().expect("tool results");
let with_image = results[0]["content"].as_array().expect("text and image");
assert_eq!(
with_image[0],
json!({"type": "text", "text": "[image/jpeg, 14 bytes]"})
);
assert_eq!(with_image[1]["type"], "image");
assert_eq!(with_image[1]["source"]["media_type"], "image/jpeg");
assert_eq!(with_image[1]["source"]["data"], JPEG_B64);
assert_eq!(
results[1]["content"], "plain text",
"text alone stays a string"
);
assert_eq!(
msgs.len(),
4,
"no extra turn: the image rides in the result"
);
}
#[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 auto_max_tokens_uses_live_discovered_ceiling() {
let adapter = test_adapter();
let config = ModelConfig {
max_tokens: 0,
resolved_context_window: Some(1_000_000),
resolved_max_output: Some(128_000),
..Default::default()
};
let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
assert_eq!(body["max_tokens"], 128_000);
}
#[test]
fn auto_max_tokens_floors_when_discovery_unresolved() {
let adapter = test_adapter();
let config = ModelConfig {
max_tokens: 0,
..Default::default()
};
let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
assert_eq!(body["max_tokens"], 8_192);
}
#[test]
fn auto_max_tokens_clamps_to_window_room() {
let adapter = test_adapter();
let config = ModelConfig {
max_tokens: 0,
system_prompt: Some("sys".to_string()),
resolved_context_window: Some(16_384),
resolved_max_output: Some(128_000),
..Default::default()
};
let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
assert_eq!(body["max_tokens"], 16_384 - 2 - 1_024);
}
#[test]
fn model_directed_system_messages_reach_the_wire_as_tagged_user_blocks() {
use crate::models::ChatMessageKind;
let mut nudge = ChatMessage::system("Reminder: the task checklist is stale.");
nudge.kind = ChatMessageKind::RecoveryNudge;
let messages = vec![ChatMessage::user("ok"), nudge];
let (_system, out) = convert_messages(&messages, Advertised::default());
assert_eq!(out.len(), 1, "merged into the adjacent user turn");
assert_eq!(out[0]["role"], "user");
let blocks = out[0]["content"].as_array().expect("content array");
assert_eq!(blocks.len(), 2, "original text plus the reminder");
assert_eq!(blocks[0]["text"], "ok");
let tagged = blocks[1]["text"].as_str().unwrap();
assert!(
tagged.contains("<system-reminder>") && tagged.contains("the task checklist is stale"),
"steering must be delivered and tagged: {tagged}",
);
}
#[test]
fn model_directed_system_message_creates_a_user_turn_when_needed() {
use crate::models::ChatMessageKind;
let mut nudge = ChatMessage::system("Resume where you stopped.");
nudge.kind = ChatMessageKind::ContextMarker;
let messages = vec![ChatMessage::assistant("partial reply"), nudge];
let (_system, out) = convert_messages(&messages, Advertised::default());
assert_eq!(out.len(), 2);
assert_eq!(out[0]["role"], "assistant");
assert_eq!(out[1]["role"], "user", "alternation stays valid");
assert!(
out[1]["content"][0]["text"]
.as_str()
.unwrap()
.contains("Resume where you stopped"),
);
}
#[test]
fn convert_messages_never_emits_consecutive_same_role_turns() {
use crate::models::ChatMessageKind;
let steering = || {
let mut m = ChatMessage::system("Reminder: the task checklist is stale.");
m.kind = ChatMessageKind::ContextMarker;
m
};
let tool_call = || {
let mut m = ChatMessage::assistant("");
m.tool_calls = Some(vec![ToolCall {
id: Some("c1".to_string()),
function: FunctionCall {
name: "read_file".into(),
arguments: json!({"path": "a.txt"}),
},
}]);
m
};
let shapes: Vec<(&str, Vec<ChatMessage>)> = vec![
(
"steering between two user turns",
vec![
ChatMessage::user("first"),
steering(),
ChatMessage::user("second"),
],
),
(
"two user turns in a row",
vec![ChatMessage::user("first"), ChatMessage::user("second")],
),
(
"user types while tool results are pending",
vec![
ChatMessage::user("read it"),
tool_call(),
ChatMessage::tool("c1", "read_file", "contents"),
ChatMessage::user("actually, stop"),
],
),
(
"two assistant turns from an interrupted continuation",
vec![
ChatMessage::user("go"),
ChatMessage::assistant("part one"),
ChatMessage::assistant("part two"),
],
),
(
"back-to-back steering",
vec![ChatMessage::user("go"), steering(), steering()],
),
(
"steering with no user turn to attach to",
vec![ChatMessage::assistant("partial"), steering()],
),
];
for (name, messages) in shapes {
let (_system, out) = convert_messages(&messages, Advertised::default());
assert!(!out.is_empty(), "{name}: the history must not vanish");
for pair in out.windows(2) {
assert_ne!(
pair[0]["role"], pair[1]["role"],
"{name}: emitted consecutive {} turns, which Anthropic rejects: {out:#?}",
pair[0]["role"],
);
}
}
}
#[test]
fn coalescing_two_user_turns_keeps_both_texts() {
let messages = vec![ChatMessage::user("first"), ChatMessage::user("second")];
let (_system, out) = convert_messages(&messages, Advertised::default());
assert_eq!(out.len(), 1);
let blocks = out[0]["content"].as_array().expect("content array");
assert_eq!(blocks.len(), 2, "both texts survive: {blocks:#?}");
assert_eq!(blocks[0]["text"], "first");
assert_eq!(blocks[1]["text"], "second");
}
#[test]
fn merged_user_turn_keeps_tool_results_first() {
let mut call = ChatMessage::assistant("");
call.tool_calls = Some(vec![ToolCall {
id: Some("c1".to_string()),
function: FunctionCall {
name: "read_file".into(),
arguments: json!({"path": "a.txt"}),
},
}]);
let messages = vec![
ChatMessage::user("read it"),
call,
ChatMessage::tool("c1", "read_file", "contents"),
ChatMessage::user("actually, stop"),
];
let (_system, out) = convert_messages(&messages, Advertised::default());
let blocks = out[2]["content"].as_array().expect("content array");
assert_eq!(out[2]["role"], "user");
assert_eq!(blocks[0]["type"], "tool_result", "{blocks:#?}");
assert_eq!(blocks[1]["type"], "text");
assert_eq!(blocks[1]["text"], "actually, stop");
}
#[test]
fn merged_assistant_turn_keeps_thinking_first() {
let mut second = ChatMessage::assistant("part two");
second.thinking = Some("more reasoning".to_string());
second.provider_continuation = Some(ProviderContinuation::anthropic("sig_xyz".to_string()));
let messages = vec![
ChatMessage::user("go"),
ChatMessage::assistant("part one"),
second,
];
let (_system, out) = convert_messages(&messages, Advertised::default());
assert_eq!(out.len(), 2);
let blocks = out[1]["content"].as_array().expect("content array");
assert_eq!(blocks[0]["type"], "thinking", "{blocks:#?}");
assert_eq!(blocks[1]["text"], "part one");
assert_eq!(blocks[2]["text"], "part two");
}
#[test]
fn merged_assistant_turn_puts_a_compaction_block_before_thinking() {
let block = json!({"type": "compaction", "content": "summary"});
let mut second = ChatMessage::assistant("part two");
second.thinking = Some("more reasoning".to_string());
second.provider_continuation = Some(ProviderContinuation::Anthropic {
signature: "sig_xyz".to_string(),
compaction: Some(block.clone()),
native_tool_calls: Vec::new(),
});
let messages = vec![
ChatMessage::user("go"),
ChatMessage::assistant("part one"),
second,
];
let (_system, out) = convert_messages(&messages, Advertised::default());
let blocks = out[1]["content"].as_array().expect("content array");
let kinds: Vec<&str> = blocks.iter().filter_map(|b| b["type"].as_str()).collect();
assert_eq!(
kinds,
["compaction", "thinking", "text", "text"],
"{blocks:#?}"
);
assert_eq!(blocks[0], block);
assert_eq!(blocks[2]["text"], "part one");
}
#[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_maps_output_schema_to_output_config_format() {
let adapter = test_adapter();
let messages = vec![ChatMessage::user("format it")];
let config = ModelConfig {
reasoning: ReasoningLevel::High,
output_schema: Some(serde_json::json!({
"type": "object",
"properties": {"answer": {"type": "integer"}}
})),
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["output_config"]["format"]["type"], "json_schema");
assert_eq!(body["output_config"]["format"]["schema"]["type"], "object");
assert_eq!(body["output_config"]["effort"], "high");
let body = adapter.build_request_body(&messages, &ModelConfig::default());
assert!(body["output_config"].get("format").is_none());
}
#[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!(
body.get("output_config").is_none(),
"Sonnet 4.5 must not get an effort field"
);
assert!(body.get("temperature").is_some());
}
#[test]
fn build_request_body_adaptive_no_temperature_for_opus_4_8() {
let adapter = AnthropicAdapter::new(
"k".to_string(),
"claude-opus-4-8".to_string(),
"https://api.anthropic.com/v1".to_string(),
)
.unwrap();
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!(
body["thinking"].get("budget_tokens").is_none(),
"Opus 4.8 rejects legacy budget_tokens"
);
assert_eq!(body["output_config"]["effort"], "high");
assert!(
body.get("temperature").is_none(),
"Opus 4.8 rejects a top-level temperature"
);
}
#[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_opus_4_5() {
let adapter = AnthropicAdapter::new(
"k".to_string(),
"claude-opus-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",
"Opus 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,
..Default::default()
};
let body = adapter.build_request_body(&messages, &config);
assert_eq!(body["thinking"]["display"], "summarized");
}
#[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_preserves_registry_selected_web_tools() {
let adapter = test_adapter();
let config = ModelConfig {
tools: ["web_fetch", "web_search"]
.into_iter()
.map(|name| {
serde_json::json!({
"type": "function",
"function": {
"name": name,
"description": "registered web tool",
"parameters": {"type": "object"}
}
})
})
.collect(),
..Default::default()
};
let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config);
let names: Vec<&str> = body["tools"]
.as_array()
.expect("tools array")
.iter()
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
.collect();
assert_eq!(names, ["web_fetch", "web_search"]);
}
#[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(&[], Advertised::default());
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");
}
#[test]
fn content_blocks_past_the_bound_are_ignored() {
let mut stream = AnthropicStream::new("claude-test".to_string());
let mut out = Vec::new();
for index in 0..(crate::constants::MAX_TOOL_CALLS + 50) {
let frame = format!(
r#"{{"type":"content_block_start","index":{index},"content_block":{{"type":"text","text":""}}}}"#
);
stream.on_frame(&frame, &mut out).unwrap();
}
assert_eq!(stream.blocks.len(), crate::constants::MAX_TOOL_CALLS);
}
}