mod stream;
use self::stream::AnthropicStreamParser;
use crate::tools::mvp_tool_definitions_json_with_subagents;
use crate::{
cancellation::AgentCancellation,
config::AnthropicCacheTtl,
model_catalog::types::ModelCatalogEntry,
providers::{
ANTHROPIC_PROVIDER, HttpRequest, HttpTransport, MessageRole, ProviderConversationItem,
ProviderEvent, ProviderRequest, ProviderToolResult,
openai::{
fetch_model_catalog_response_text_cancellable, package_user_agent,
response_instructions,
},
openai_stream::stream_with_transport_parser,
replay_trace::ReplayDropTrace,
},
thinking::ThinkingLevel,
};
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, HashSet},
fmt,
sync::OnceLock,
};
pub(crate) const ANTHROPIC_MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
pub(crate) const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
pub(crate) const ANTHROPIC_VERSION: &str = "2023-06-01";
pub(crate) const DEFAULT_MAX_TOKENS: u64 = 4096;
#[derive(Clone)]
pub struct AnthropicProvider<T> {
model: String,
api_key: String,
transport: T,
cache_ttl: Option<AnthropicCacheTtl>,
max_output_tokens: Option<u64>,
thinking_level: ThinkingLevel,
}
impl<T: fmt::Debug> fmt::Debug for AnthropicProvider<T> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AnthropicProvider")
.field("model", &self.model)
.field("api_key", &"<redacted>")
.field("transport", &self.transport)
.field("cache_ttl", &self.cache_ttl)
.field("max_output_tokens", &self.max_output_tokens)
.field("thinking_level", &self.thinking_level)
.finish()
}
}
impl<T> AnthropicProvider<T> {
pub fn new(model: impl Into<String>, api_key: impl Into<String>, transport: T) -> Self {
Self {
model: model.into(),
api_key: api_key.into(),
transport,
cache_ttl: None,
max_output_tokens: None,
thinking_level: ThinkingLevel::Default,
}
}
pub fn with_cache_ttl(mut self, cache_ttl: Option<AnthropicCacheTtl>) -> Self {
self.cache_ttl = cache_ttl;
self
}
pub fn with_max_output_tokens(mut self, max: Option<u64>) -> Self {
self.max_output_tokens = max;
self
}
pub fn with_thinking_level(mut self, level: ThinkingLevel) -> Self {
self.thinking_level = level;
self
}
pub fn build_http_request(&self, request: &ProviderRequest) -> HttpRequest {
HttpRequest {
method: "POST".to_string(),
url: ANTHROPIC_MESSAGES_URL.to_string(),
headers: anthropic_headers(&self.api_key, "text/event-stream"),
body: anthropic_messages_body_with_cache_ttl(
&self.model,
request,
self.cache_ttl,
self.max_output_tokens,
self.thinking_level,
),
}
}
pub fn build_model_catalog_request(&self) -> HttpRequest {
HttpRequest {
method: "GET".to_string(),
url: format!("{ANTHROPIC_MODELS_URL}?limit=1000"),
headers: anthropic_headers(&self.api_key, "application/json"),
body: Value::Null,
}
}
pub fn discover_model_catalog(&self) -> anyhow::Result<Vec<ModelCatalogEntry>> {
self.discover_model_catalog_cancellable(&AgentCancellation::default())
}
pub fn discover_model_catalog_cancellable(
&self,
cancellation: &AgentCancellation,
) -> anyhow::Result<Vec<ModelCatalogEntry>> {
let text = fetch_model_catalog_response_text_cancellable(
self.build_model_catalog_request(),
ANTHROPIC_PROVIDER,
cancellation,
)?;
parse_anthropic_model_catalog_response(&text)
}
}
impl<T: HttpTransport + Send + Sync> crate::providers::Provider for AnthropicProvider<T> {
fn stream_cancellable(
&self,
request: ProviderRequest,
cancellation: &AgentCancellation,
on_event: &mut dyn FnMut(ProviderEvent) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let semantic_progress_timeout = request.semantic_progress_timeout_or_default();
stream_with_transport_parser(
&self.transport,
self.build_http_request(&request),
cancellation,
semantic_progress_timeout,
AnthropicStreamParser::default,
on_event,
)
}
}
fn anthropic_headers(api_key: &str, accept: &str) -> BTreeMap<String, String> {
BTreeMap::from([
("accept".to_string(), accept.to_string()),
(
"anthropic-version".to_string(),
ANTHROPIC_VERSION.to_string(),
),
("content-type".to_string(), "application/json".to_string()),
("user-agent".to_string(), package_user_agent()),
("x-api-key".to_string(), api_key.to_string()),
])
}
#[cfg(test)]
pub(crate) fn anthropic_messages_body(
model: &str,
request: &ProviderRequest,
max_output_tokens: Option<u64>,
) -> Value {
anthropic_messages_body_with_cache_ttl(
model,
request,
None,
max_output_tokens,
ThinkingLevel::Default,
)
}
pub(crate) fn anthropic_messages_body_with_cache_ttl(
model: &str,
request: &ProviderRequest,
cache_ttl: Option<AnthropicCacheTtl>,
max_output_tokens: Option<u64>,
thinking_level: ThinkingLevel,
) -> Value {
let mut state = AnthropicBodyBuildState::default();
for item in request.conversation_items_iter() {
state.append_conversation_item(item);
}
state.trace_dropped_replay_items();
let max_tokens = max_output_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
let mut body = json!({
"model": model,
"max_tokens": max_tokens,
"stream": request.stream,
"messages": state.messages,
});
match anthropic_thinking_mode(model) {
AnthropicThinkingMode::Manual => {
if let Some(budget_tokens) =
anthropic_thinking_budget_tokens(thinking_level, max_tokens)
{
body["thinking"] = json!({"type":"enabled","budget_tokens": budget_tokens});
}
}
AnthropicThinkingMode::Adaptive => {
if let Some(effort) = thinking_level.explicit_effort() {
body["thinking"] = json!({"type":"adaptive"});
body["output_config"] = json!({"effort": effort});
}
}
}
if let Some(cache_ttl) = cache_ttl {
body["cache_control"] = anthropic_cache_control_json(cache_ttl);
}
let system = response_instructions(request);
if !system.is_empty() {
body["system"] = json!(system);
}
if let Some(tool_definitions) = anthropic_tools_for_request(request) {
body["tools"] = tool_definitions;
}
body
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AnthropicThinkingMode {
Manual,
Adaptive,
}
fn anthropic_thinking_mode(model: &str) -> AnthropicThinkingMode {
let adaptive = model.starts_with("claude-fable-")
|| model.starts_with("claude-mythos-")
|| anthropic_model_version_at_least(model, "claude-sonnet-", 5, 0)
|| anthropic_model_version_at_least(model, "claude-opus-", 4, 7);
if adaptive {
AnthropicThinkingMode::Adaptive
} else {
AnthropicThinkingMode::Manual
}
}
fn anthropic_model_version_at_least(
model: &str,
family_prefix: &str,
minimum_major: u64,
minimum_minor: u64,
) -> bool {
let Some(version) = model.strip_prefix(family_prefix) else {
return false;
};
let mut parts = version.split('-');
let Some(major) = parts.next().and_then(|part| part.parse::<u64>().ok()) else {
return false;
};
let minor = parts
.next()
.and_then(|part| part.parse::<u64>().ok())
.unwrap_or(0);
(major, minor) >= (minimum_major, minimum_minor)
}
fn anthropic_thinking_budget_tokens(level: ThinkingLevel, max_tokens: u64) -> Option<u64> {
let budget = match level {
ThinkingLevel::High => 16_384,
ThinkingLevel::Max => 32_768,
_ => return None,
};
(max_tokens > 1).then_some(budget.min(max_tokens.saturating_sub(1)))
}
fn anthropic_cache_control_json(cache_ttl: AnthropicCacheTtl) -> Value {
match cache_ttl {
AnthropicCacheTtl::FiveMinutes => json!({"type":"ephemeral"}),
AnthropicCacheTtl::OneHour => json!({"type":"ephemeral","ttl":"1h"}),
}
}
struct AnthropicBodyBuildState {
messages: Vec<Value>,
seen_tool_calls: HashSet<String>,
seen_tool_results: HashSet<String>,
replay_drop_trace: ReplayDropTrace,
}
impl Default for AnthropicBodyBuildState {
fn default() -> Self {
Self {
messages: Vec::new(),
seen_tool_calls: HashSet::new(),
seen_tool_results: HashSet::new(),
replay_drop_trace: ReplayDropTrace::new("anthropic"),
}
}
}
impl AnthropicBodyBuildState {
fn append_conversation_item(&mut self, item: &ProviderConversationItem) {
match item {
ProviderConversationItem::Message(message) if message.role != MessageRole::System => {
self.push_message(
message.role.as_api_str(),
vec![json!({"type":"text","text": message.content})],
);
}
ProviderConversationItem::Message(_) => {}
ProviderConversationItem::ResponseItem(item) => self.append_response_item(item),
ProviderConversationItem::ToolResult(result) => self.append_tool_result(result),
ProviderConversationItem::LegacyReplayNote {
event_type,
content,
} => {
self.push_message(
"user",
vec![json!({
"type":"text",
"text": ProviderConversationItem::legacy_note_text(event_type, content),
})],
);
}
}
}
fn append_response_item(&mut self, item: &Value) {
if let Some(block) = anthropic_thinking_block(item) {
self.push_message("assistant", vec![block]);
return;
}
if item.get("type").and_then(Value::as_str) == Some("function_call") {
let Some(call_id) = item.get("call_id").and_then(Value::as_str) else {
self.drop_replay_item("function_call_missing_call_id");
return;
};
let Some(name) = item.get("name").and_then(Value::as_str) else {
self.drop_replay_item("function_call_missing_name");
return;
};
if !self.seen_tool_calls.insert(call_id.to_string()) {
return;
}
self.push_message(
"assistant",
vec![json!({
"type":"tool_use",
"id": call_id,
"name": name,
"input": anthropic_tool_input(item.get("arguments").unwrap_or(&Value::Null)),
})],
);
return;
}
if item.get("type").and_then(Value::as_str) == Some("function_call_output") {
let Some(call_id) = item.get("call_id").and_then(Value::as_str) else {
self.drop_replay_item("function_call_output_missing_call_id");
return;
};
if !self.seen_tool_results.insert(call_id.to_string()) {
return;
}
self.push_message(
"user",
vec![json!({
"type":"tool_result",
"tool_use_id": call_id,
"content": text_from_value(item.get("output")),
})],
);
return;
}
let Some(role) = item.get("role").and_then(Value::as_str) else {
self.drop_replay_item("message_missing_role");
return;
};
if !matches!(role, "user" | "assistant") {
self.drop_replay_item("message_unsupported_role");
return;
}
self.push_message(
role,
vec![json!({
"type":"text",
"text": text_from_value(item.get("content")),
})],
);
}
fn append_tool_result(&mut self, result: &ProviderToolResult) {
if !self.seen_tool_results.insert(result.call_id.clone()) {
return;
}
self.push_message(
"user",
vec![json!({
"type":"tool_result",
"tool_use_id": result.call_id,
"content": anthropic_tool_result_content(result),
"is_error": !result.success,
})],
);
}
fn push_message(&mut self, role: &str, mut content: Vec<Value>) {
if role == "tool" {
return;
}
if let Some(last) = self.messages.last_mut()
&& last.get("role").and_then(Value::as_str) == Some(role)
&& let Some(parts) = last.get_mut("content").and_then(Value::as_array_mut)
{
parts.append(&mut content);
return;
}
self.messages
.push(json!({"role": role, "content": content}));
}
fn drop_replay_item(&mut self, reason: &str) {
self.replay_drop_trace.drop_item(reason);
}
fn trace_dropped_replay_items(&self) {
self.replay_drop_trace.trace_summary();
}
}
fn anthropic_tool_result_content(result: &ProviderToolResult) -> String {
if !result.success && result.output.trim().is_empty() {
return "Tool failed with no output.".to_string();
}
result.output.clone()
}
fn anthropic_tool_input(arguments: &Value) -> Value {
match arguments {
Value::String(text) => serde_json::from_str(text).unwrap_or_else(|_| json!({})),
Value::Null => json!({}),
value => value.clone(),
}
}
fn anthropic_thinking_block(item: &Value) -> Option<Value> {
match item.get("type").and_then(Value::as_str) {
Some("thinking") => Some(json!({
"type":"thinking",
"thinking": item.get("thinking").and_then(Value::as_str).unwrap_or_default(),
"signature": item.get("signature").and_then(Value::as_str).unwrap_or_default(),
})),
Some("redacted_thinking") => Some(json!({
"type":"redacted_thinking",
"data": item.get("data").and_then(Value::as_str).unwrap_or_default(),
})),
_ => None,
}
}
fn text_from_value(value: Option<&Value>) -> String {
match value {
Some(Value::String(text)) => text.clone(),
Some(Value::Null) | None => " ".to_string(),
Some(value) => value.to_string(),
}
}
static ANTHROPIC_TOOLS_JSON: OnceLock<Value> = OnceLock::new();
static ANTHROPIC_TOOLS_WITHOUT_SUBAGENTS_JSON: OnceLock<Value> = OnceLock::new();
fn anthropic_tools_for_request(request: &ProviderRequest) -> Option<Value> {
if let Some(include_subagents) = request.static_tool_definitions_variant() {
let cache = if include_subagents {
&ANTHROPIC_TOOLS_JSON
} else {
&ANTHROPIC_TOOLS_WITHOUT_SUBAGENTS_JSON
};
return Some(
cache
.get_or_init(|| {
anthropic_tools_json(mvp_tool_definitions_json_with_subagents(
include_subagents,
))
})
.clone(),
);
}
request
.tool_definitions_json_if_enabled()
.map(anthropic_tools_json)
}
fn anthropic_tools_json(tool_definitions: Value) -> Value {
let Value::Array(tools) = tool_definitions else {
return Value::Array(Vec::new());
};
Value::Array(
tools
.into_iter()
.map(|mut tool| {
let (name, description, input_schema) = match tool.as_object_mut() {
Some(tool) => (
tool.remove("name").unwrap_or(Value::Null),
tool.remove("description").unwrap_or(Value::Null),
tool.remove("parameters").unwrap_or(Value::Null),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({
"name": name,
"description": description,
"input_schema": input_schema,
})
})
.collect(),
)
}
pub(crate) fn parse_anthropic_model_catalog_response(
text: &str,
) -> anyhow::Result<Vec<ModelCatalogEntry>> {
let value: Value = serde_json::from_str(text)?;
let data = value
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| anyhow::anyhow!("anthropic model discovery response missing data array"))?;
let mut entries = Vec::new();
for item in data {
let Some(model) = item
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
else {
continue;
};
let mut entry = ModelCatalogEntry::new(ANTHROPIC_PROVIDER, model);
entry.reasoning_efforts = Some(ThinkingLevel::HIGH_MAX.to_vec());
entry.supports_reasoning = Some(true);
entry.display_name = item
.get("display_name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string);
entry.context_window = item.get("max_input_tokens").and_then(Value::as_u64);
entry.max_output_tokens = item.get("max_tokens").and_then(Value::as_u64);
entries.push(entry);
}
if entries.is_empty() {
anyhow::bail!("anthropic model discovery returned no usable models");
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::{ChatMessage, Provider, ProviderToolResult, ToolCall, Usage};
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
};
enum ScriptStep {
Chunks(Vec<&'static str>),
Error(anyhow::Error),
}
struct ScriptedTransport {
steps: Mutex<VecDeque<ScriptStep>>,
attempts: Arc<Mutex<usize>>,
}
impl ScriptedTransport {
fn new(steps: Vec<ScriptStep>) -> Self {
Self {
steps: Mutex::new(steps.into()),
attempts: Arc::new(Mutex::new(0)),
}
}
fn attempts_handle(&self) -> Arc<Mutex<usize>> {
Arc::clone(&self.attempts)
}
}
impl HttpTransport for ScriptedTransport {
fn stream_json(
&self,
request: HttpRequest,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
self.stream_json_cancellable(request, &AgentCancellation::default(), on_chunk)
}
fn stream_json_cancellable(
&self,
_request: HttpRequest,
cancellation: &AgentCancellation,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
cancellation.check()?;
*self.attempts.lock().unwrap() += 1;
match self.steps.lock().unwrap().pop_front().unwrap() {
ScriptStep::Chunks(chunks) => {
for chunk in chunks {
cancellation.check()?;
on_chunk(chunk)?;
}
Ok(())
}
ScriptStep::Error(error) => Err(error),
}
}
fn stream_json_cancellable_with_semantic_deadline(
&self,
request: HttpRequest,
cancellation: &AgentCancellation,
_semantic_deadline: &std::sync::atomic::AtomicU64,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
self.stream_json_cancellable(request, cancellation, on_chunk)
}
}
fn retryable_503_error() -> anyhow::Error {
crate::providers::error::ProviderError::http_status(
503,
"provider request failed for https://api.anthropic.com/v1/messages with status 503 Service Unavailable: upstream overloaded",
)
.into()
}
#[derive(Default)]
struct CapturingTransport {
chunks: Vec<String>,
}
impl HttpTransport for CapturingTransport {
fn stream_json(
&self,
request: HttpRequest,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
self.stream_json_cancellable(request, &AgentCancellation::default(), on_chunk)
}
fn stream_json_cancellable(
&self,
_request: HttpRequest,
cancellation: &AgentCancellation,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
for chunk in &self.chunks {
cancellation.check()?;
on_chunk(chunk)?;
}
Ok(())
}
fn stream_json_cancellable_with_semantic_deadline(
&self,
request: HttpRequest,
cancellation: &AgentCancellation,
_semantic_deadline: &std::sync::atomic::AtomicU64,
on_chunk: &mut dyn FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
self.stream_json_cancellable(request, cancellation, on_chunk)
}
}
#[test]
fn anthropic_request_encodes_high_and_max_thinking_with_budget_below_max_tokens() {
let high = AnthropicProvider::new("claude-test", "secret", CapturingTransport::default())
.with_max_output_tokens(Some(20_000))
.with_thinking_level(ThinkingLevel::High)
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(
high.body["thinking"],
json!({"type":"enabled","budget_tokens":16_384})
);
let max = AnthropicProvider::new("claude-test", "secret", CapturingTransport::default())
.with_max_output_tokens(Some(16_000))
.with_thinking_level(ThinkingLevel::Max)
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(max.body["thinking"]["budget_tokens"], 15_999);
}
#[test]
fn anthropic_request_encodes_adaptive_thinking_for_current_effort_models() {
for model in [
"claude-fable-5-1",
"claude-mythos-5-1",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-9",
"claude-opus-5",
"claude-sonnet-5",
"claude-sonnet-6",
] {
let http = AnthropicProvider::new(model, "secret", CapturingTransport::default())
.with_thinking_level(ThinkingLevel::Max)
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(http.body["thinking"], json!({"type":"adaptive"}), "{model}");
assert_eq!(
http.body["output_config"],
json!({"effort":"max"}),
"{model}"
);
}
}
#[test]
fn anthropic_request_keeps_manual_thinking_for_extended_thinking_models() {
for model in ["claude-haiku-4-5", "claude-sonnet-4-5", "claude-opus-4-6"] {
let http = AnthropicProvider::new(model, "secret", CapturingTransport::default())
.with_max_output_tokens(Some(20_000))
.with_thinking_level(ThinkingLevel::High)
.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(
http.body["thinking"],
json!({"type":"enabled","budget_tokens":16_384}),
"{model}"
);
assert!(http.body.get("output_config").is_none(), "{model}");
}
}
#[test]
fn anthropic_request_omits_thinking_for_default_level() {
let provider =
AnthropicProvider::new("claude-test", "secret", CapturingTransport::default())
.with_thinking_level(ThinkingLevel::Default);
let http = provider.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert!(http.body.get("thinking").is_none());
}
#[test]
fn anthropic_request_construction_targets_messages_endpoint() {
let provider =
AnthropicProvider::new("claude-test", "secret", CapturingTransport::default());
let http = provider.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(http.method, "POST");
assert_eq!(http.url, ANTHROPIC_MESSAGES_URL);
assert_eq!(http.body["model"], "claude-test");
assert_eq!(http.body["max_tokens"], DEFAULT_MAX_TOKENS);
assert_eq!(http.body["stream"], true);
}
#[test]
fn anthropic_request_uses_configured_max_output_tokens_when_provided() {
let provider =
AnthropicProvider::new("claude-test", "secret", CapturingTransport::default())
.with_max_output_tokens(Some(8192));
let http = provider.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(http.body["max_tokens"], 8192);
}
#[test]
fn anthropic_request_falls_back_to_default_max_tokens_when_none() {
let provider =
AnthropicProvider::new("claude-test", "secret", CapturingTransport::default());
let http = provider.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(http.body["max_tokens"], DEFAULT_MAX_TOKENS);
}
#[test]
fn anthropic_request_default_omits_cache_control() {
let body = anthropic_messages_body(
"claude-test",
&ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
None,
);
assert!(body.get("cache_control").is_none());
}
#[test]
fn anthropic_request_one_hour_cache_control_emits_ttl() {
let provider =
AnthropicProvider::new("claude-test", "secret", CapturingTransport::default())
.with_cache_ttl(Some(AnthropicCacheTtl::OneHour));
let http = provider.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(
http.body["cache_control"],
json!({"type":"ephemeral","ttl":"1h"})
);
assert!(!http.headers.contains_key("anthropic-beta"));
}
#[test]
fn anthropic_request_uses_x_api_key_and_anthropic_version_headers() {
let provider =
AnthropicProvider::new("claude-test", "secret", CapturingTransport::default());
let http = provider.build_http_request(&ProviderRequest::new(
"ignored",
vec![ChatMessage::user("hello")],
));
assert_eq!(http.headers["x-api-key"], "secret");
assert_eq!(http.headers["anthropic-version"], ANTHROPIC_VERSION);
assert_eq!(http.headers["accept"], "text/event-stream");
assert!(!http.headers.contains_key("authorization"));
}
#[test]
fn anthropic_request_moves_system_to_top_level_system() {
let body = anthropic_messages_body(
"claude-test",
&ProviderRequest::new(
"ignored",
vec![ChatMessage::system("sys"), ChatMessage::user("hello")],
),
None,
);
assert_eq!(body["system"], "sys");
assert_eq!(body["messages"][0]["role"], "user");
}
#[test]
fn anthropic_request_converts_tools_to_input_schema() {
let body = anthropic_messages_body(
"claude-test",
&ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
None,
);
assert_eq!(body["tools"][0]["name"], "read");
assert!(body["tools"][0].get("input_schema").is_some());
assert!(body["tools"][0].get("parameters").is_none());
}
#[test]
fn anthropic_static_tool_cache_matches_uncached_transform_for_both_variants() {
for include_subagents in [true, false] {
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_subagents_tool_enabled(include_subagents);
let body = anthropic_messages_body("claude-test", &request, None);
let expected =
anthropic_tools_json(mvp_tool_definitions_json_with_subagents(include_subagents));
assert_eq!(body["tools"], expected);
}
}
#[test]
fn anthropic_request_appends_dynamic_mcp_tools() {
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_dynamic_tool_definitions(vec![json!({
"type":"function",
"name":"mcp__mock__echo",
"description":"Echo",
"parameters":{"type":"object","properties":{"text":{"type":"string"}}}
})]);
let body = anthropic_messages_body("claude-test", &request, None);
let tool = body["tools"].as_array().unwrap().last().unwrap();
assert_eq!(tool["name"], "mcp__mock__echo");
assert_eq!(tool["input_schema"]["properties"]["text"]["type"], "string");
assert!(tool.get("parameters").is_none());
}
#[test]
fn anthropic_request_converts_canonical_function_call_and_tool_result() {
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_response_items(vec![json!({"type":"function_call","call_id":"call_1","name":"read","arguments":"{\"path\":\"a.txt\"}"})])
.with_tool_results(vec![ProviderToolResult { call_id: "call_1".into(), tool_name: "read".into(), success: false, output: "boom".into() }]);
let body = anthropic_messages_body("claude-test", &request, None);
assert_eq!(body["messages"][1]["content"][0]["type"], "tool_use");
assert_eq!(
body["messages"][1]["content"][0]["input"],
json!({"path":"a.txt"})
);
assert_eq!(body["messages"][2]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][2]["content"][0]["content"], "boom");
assert_eq!(body["messages"][2]["content"][0]["is_error"], true);
}
#[test]
fn anthropic_request_preserves_compressed_tool_result_output_verbatim() {
let compressed =
"[tool_output_compression]\ntool: shell\nrule: bash.git_diff\n\nfiles_touched: 1";
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_response_items(vec![json!({
"type": "function_call",
"call_id": "call_1",
"name": "shell",
"arguments": "{}"
})])
.with_tool_results(vec![ProviderToolResult {
call_id: "call_1".into(),
tool_name: "shell".into(),
success: true,
output: compressed.to_string(),
}]);
let body = anthropic_messages_body("claude-test", &request, None);
assert_eq!(
body["messages"][2]["content"][0]["content"].as_str(),
Some(compressed)
);
}
#[test]
fn anthropic_request_preserves_thinking_blocks_before_tool_use_for_continuation() {
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_response_items(vec![
json!({"type":"thinking","thinking":"kept private","signature":"sig_1"}),
json!({"type":"redacted_thinking","data":"encrypted_1"}),
json!({"type":"function_call","call_id":"call_1","name":"read","arguments":"{\"path\":\"a.txt\"}"}),
])
.with_tool_results(vec![ProviderToolResult {
call_id: "call_1".into(),
tool_name: "read".into(),
success: true,
output: "file text".into(),
}]);
let body = anthropic_messages_body("claude-test", &request, None);
let assistant_content = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(
assistant_content[0],
json!({"type":"thinking","thinking":"kept private","signature":"sig_1"})
);
assert_eq!(
assistant_content[1],
json!({"type":"redacted_thinking","data":"encrypted_1"})
);
assert_eq!(assistant_content[2]["type"], "tool_use");
assert_eq!(assistant_content[2]["id"], "call_1");
assert_eq!(body["messages"][2]["content"][0]["type"], "tool_result");
}
#[test]
fn anthropic_request_failed_tool_result_empty_output_uses_fallback() {
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_response_items(vec![
json!({"type":"function_call","call_id":"call_1","name":"grep","arguments":"{}"}),
])
.with_tool_results(vec![ProviderToolResult {
call_id: "call_1".into(),
tool_name: "grep".into(),
success: false,
output: "".into(),
}]);
let body = anthropic_messages_body("claude-test", &request, None);
assert_eq!(body["messages"][2]["content"][0]["type"], "tool_result");
assert_eq!(
body["messages"][2]["content"][0]["content"],
"Tool failed with no output."
);
assert_eq!(body["messages"][2]["content"][0]["is_error"], true);
}
#[test]
fn anthropic_request_successful_tool_result_empty_output_stays_empty() {
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_response_items(vec![
json!({"type":"function_call","call_id":"call_1","name":"grep","arguments":"{}"}),
])
.with_tool_results(vec![ProviderToolResult {
call_id: "call_1".into(),
tool_name: "grep".into(),
success: true,
output: "".into(),
}]);
let body = anthropic_messages_body("claude-test", &request, None);
assert_eq!(body["messages"][2]["content"][0]["type"], "tool_result");
assert_eq!(body["messages"][2]["content"][0]["content"], "");
assert_eq!(body["messages"][2]["content"][0]["is_error"], false);
}
#[test]
fn anthropic_request_omits_tools_when_disabled() {
let body = anthropic_messages_body(
"claude-test",
&ProviderRequest::new_without_tools("ignored", vec![ChatMessage::user("hello")]),
None,
);
assert!(body.get("tools").is_none());
}
#[test]
fn anthropic_request_omits_tools_when_all_tools_filtered() {
let disabled = crate::tools::MVP_TOOL_CAPABILITIES
.iter()
.map(|tool| tool.canonical_name().to_string())
.collect::<Vec<_>>();
let request = ProviderRequest::new("ignored", vec![ChatMessage::user("hello")])
.with_disabled_tool_names(disabled);
let body = anthropic_messages_body("claude-test", &request, None);
assert!(body.get("tools").is_none());
}
#[test]
fn anthropic_stream_parser_emits_text_usage_and_done() {
let transport = CapturingTransport { chunks: vec![concat!(
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-test\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":2,\"cache_read_input_tokens\":5}}}\n\n",
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
"data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":3,\"cache_creation_input_tokens\":7}}\n\n",
"data: {\"type\":\"message_stop\"}\n\n"
).to_string()] };
let provider = AnthropicProvider::new("claude-test", "secret", transport);
let mut events = Vec::new();
provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
&mut |event| {
events.push(event);
Ok(())
},
)
.unwrap();
assert!(events.contains(&ProviderEvent::TextDelta("hi".to_string())));
assert!(events.contains(&ProviderEvent::UsageObserved(
crate::providers::stream::UsageObservation {
usage: Usage {
input: 2,
output: 0,
total: 7,
cache_read: 5,
..Usage::default()
},
presence: crate::providers::stream::UsagePresence {
input: true,
cache_read: true,
..Default::default()
},
}
)));
assert!(events.contains(&ProviderEvent::UsageObserved(
crate::providers::stream::UsageObservation {
usage: Usage {
input: 2,
output: 3,
total: 17,
cache_read: 5,
cache_write: 7,
..Usage::default()
},
presence: crate::providers::stream::UsagePresence {
output: true,
cache_write: true,
..Default::default()
},
}
)));
assert!(events.contains(&ProviderEvent::Done));
}
#[test]
fn anthropic_stream_parser_emits_tool_call_after_input_json_complete() {
let transport = CapturingTransport { chunks: vec![concat!(
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"read\"}}\n\n",
"data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\"}}\n\n",
"data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"a.txt\\\"}\"}}\n\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"data: {\"type\":\"message_stop\"}\n\n"
).to_string()] };
let provider = AnthropicProvider::new("claude-test", "secret", transport);
let mut events = Vec::new();
provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
&mut |event| {
events.push(event);
Ok(())
},
)
.unwrap();
assert!(events.contains(&ProviderEvent::ResponseItem(json!({"type":"function_call","call_id":"call_1","name":"read","arguments":"{\"path\":\"a.txt\"}","status":"completed"}))));
assert!(events.contains(&ProviderEvent::ToolCall(ToolCall {
id: "call_1".into(),
name: "read".into(),
arguments: json!({"path":"a.txt"})
})));
}
#[test]
fn anthropic_provider_retries_retryable_http_status_before_events() {
let transport = ScriptedTransport::new(vec![
ScriptStep::Error(retryable_503_error()),
ScriptStep::Chunks(vec!["data: {\"type\":\"message_stop\"}\n\n"]),
]);
let attempts = transport.attempts_handle();
let provider = AnthropicProvider::new("claude-test", "secret", transport);
let mut events = Vec::new();
provider
.stream(
ProviderRequest::new("ignored", vec![ChatMessage::user("hello")]),
&mut |event| {
events.push(event);
Ok(())
},
)
.unwrap();
assert_eq!(*attempts.lock().unwrap(), 2);
assert_eq!(events, vec![ProviderEvent::Done]);
}
#[test]
fn anthropic_model_catalog_request_uses_models_endpoint_and_headers() {
let provider =
AnthropicProvider::new("claude-test", "secret", CapturingTransport::default());
let http = provider.build_model_catalog_request();
assert_eq!(http.method, "GET");
assert_eq!(http.url, format!("{ANTHROPIC_MODELS_URL}?limit=1000"));
assert_eq!(http.headers["x-api-key"], "secret");
assert_eq!(http.headers["accept"], "application/json");
}
#[test]
fn anthropic_model_catalog_parser_maps_model_info_and_thinking_contract() {
let entries = parse_anthropic_model_catalog_response(r#"{"data":[{"id":"claude-fable-5-1","display_name":"Claude Fable","max_input_tokens":1000000,"max_tokens":128000},{"id":""}]}"#).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "anthropic/claude-fable-5-1");
assert_eq!(entries[0].display_name.as_deref(), Some("Claude Fable"));
assert_eq!(entries[0].context_window, Some(1_000_000));
assert_eq!(entries[0].max_output_tokens, Some(128_000));
assert_eq!(
entries[0].reasoning_efforts,
Some(ThinkingLevel::HIGH_MAX.to_vec())
);
assert_eq!(entries[0].supports_reasoning, Some(true));
}
}