use std::env;
use std::fmt::{Debug, Formatter, Result as FmtResult};
use std::time::Duration;
use async_trait::async_trait;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, RETRY_AFTER};
use reqwest::{RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::{debug, instrument, warn};
use super::client::{self, with_retries, AttemptError, HttpRetryConfig, DEFAULT_TIMEOUT_SECS};
use super::sse::create_sse_stream;
use crate::types::{
ChatMessage, ChatRequest, ChatResponse, ChatStream, ErrorKind, LlmCapabilities, LlmProvider,
MessageRole, RunnerError, StreamChunk, TokenUsage, ToolCallRequest, ToolChoice, ToolDefinition,
};
const PROVIDER_NAME: &str = "anthropic";
const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
const ANTHROPIC_DEFAULT_MODEL_ENV: &str = "ANTHROPIC_DEFAULT_MODEL";
const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
const ANTHROPIC_MAX_TOKENS_ENV: &str = "ANTHROPIC_MAX_TOKENS";
const ANTHROPIC_EFFORT_ENV: &str = "ANTHROPIC_EFFORT";
const ANTHROPIC_THINKING_ENV: &str = "ANTHROPIC_THINKING";
const ANTHROPIC_ENV_PREFIX: &str = "ANTHROPIC";
const DEFAULT_MODEL: &str = "claude-haiku-5-5";
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const DEFAULT_MAX_TOKENS: u32 = 16_000;
const API_VERSION: &str = "2023-06-01";
const AVAILABLE_MODELS: &[&str] = &["claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5"];
const SAMPLING_MODEL_PREFIXES: &[&str] = &[
"claude-3",
"claude-haiku-4",
"claude-sonnet-4",
"claude-opus-4-0",
"claude-opus-4-1",
"claude-opus-4-2",
"claude-opus-4-5",
"claude-opus-4-6",
];
const FORCED_TOOL_CHOICE_REJECTED: &[&str] = &[
"claude-opus-5-5",
"claude-sonnet-5-5",
"claude-fable-5-1",
"claude-mythos-5-1",
];
const PRE_EFFORT_MODEL_PREFIXES: &[&str] = &[
"claude-3",
"claude-haiku-4",
"claude-sonnet-4-0",
"claude-sonnet-4-2",
"claude-sonnet-4-5",
"claude-opus-4-0",
"claude-opus-4-1",
"claude-opus-4-2",
];
const PRE_ADAPTIVE_MODEL_PREFIXES: &[&str] = &["claude-opus-4-5"];
const THINKING_ALWAYS_ON: &[&str] = &["claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5"];
const THINKING_OFF_UP_TO_HIGH: &[&str] = &["claude-haiku-5-5", "claude-opus-5"];
const PROMPT_TOO_LONG: &str = "prompt is too long";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnthropicEffort {
Low,
Medium,
High,
XHigh,
Max,
}
impl AnthropicEffort {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
"max" => Some(Self::Max),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnthropicThinking {
Adaptive,
Disabled,
}
impl AnthropicThinking {
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"adaptive" => Some(Self::Adaptive),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
}
#[derive(Clone)]
pub struct AnthropicConfig {
pub api_key: String,
pub model: String,
pub base_url: String,
pub max_tokens: u32,
pub effort: Option<AnthropicEffort>,
pub thinking: Option<AnthropicThinking>,
pub timeout: Duration,
pub retry: HttpRetryConfig,
}
impl AnthropicConfig {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
model: DEFAULT_MODEL.to_owned(),
base_url: DEFAULT_BASE_URL.to_owned(),
max_tokens: DEFAULT_MAX_TOKENS,
effort: None,
thinking: None,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
retry: HttpRetryConfig::default(),
}
}
pub fn from_env() -> Result<Self, RunnerError> {
let api_key = env_value(ANTHROPIC_API_KEY_ENV).ok_or_else(missing_key)?;
Self::shared_settings_from_env(api_key)
}
pub fn pool_from_env() -> Result<Vec<Self>, RunnerError> {
let keys = numbered_api_keys(env_value);
let Some(first) = keys.first() else {
return Err(missing_key());
};
let shared = Self::shared_settings_from_env(first.clone())?;
Ok(keys
.into_iter()
.map(|api_key| Self {
api_key,
..shared.clone()
})
.collect())
}
fn shared_settings_from_env(api_key: String) -> Result<Self, RunnerError> {
let defaults = Self::new(api_key);
let max_tokens = match env_value(ANTHROPIC_MAX_TOKENS_ENV) {
Some(raw) => raw
.trim()
.parse::<u32>()
.ok()
.filter(|n| *n > 0)
.ok_or_else(|| {
RunnerError::config(format!(
"{ANTHROPIC_MAX_TOKENS_ENV} must be a positive integer, got {raw:?}"
))
})?,
None => defaults.max_tokens,
};
let effort = env_value(ANTHROPIC_EFFORT_ENV)
.map(|raw| {
AnthropicEffort::parse(&raw).ok_or_else(|| {
RunnerError::config(format!(
"{ANTHROPIC_EFFORT_ENV} must be one of low, medium, high, xhigh, max; got {raw:?}"
))
})
})
.transpose()?;
let thinking = env_value(ANTHROPIC_THINKING_ENV)
.map(|raw| {
AnthropicThinking::parse(&raw).ok_or_else(|| {
RunnerError::config(format!(
"{ANTHROPIC_THINKING_ENV} must be adaptive or disabled; got {raw:?}"
))
})
})
.transpose()?;
if thinking == Some(AnthropicThinking::Disabled)
&& matches!(effort, Some(AnthropicEffort::XHigh | AnthropicEffort::Max))
{
return Err(RunnerError::config(format!(
"{ANTHROPIC_THINKING_ENV}=disabled is refused at effort xhigh or max; lower {ANTHROPIC_EFFORT_ENV} or use adaptive thinking"
)));
}
Ok(Self {
model: env_value(ANTHROPIC_DEFAULT_MODEL_ENV).unwrap_or(defaults.model),
base_url: env_value(ANTHROPIC_BASE_URL_ENV).unwrap_or(defaults.base_url),
max_tokens,
effort,
thinking,
retry: HttpRetryConfig::from_env(ANTHROPIC_ENV_PREFIX),
api_key: defaults.api_key,
timeout: defaults.timeout,
})
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
#[must_use]
pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
#[must_use]
pub const fn with_effort(mut self, effort: AnthropicEffort) -> Self {
self.effort = Some(effort);
self
}
#[must_use]
pub const fn with_thinking(mut self, thinking: AnthropicThinking) -> Self {
self.thinking = Some(thinking);
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_retry(mut self, retry: HttpRetryConfig) -> Self {
self.retry = retry;
self
}
}
impl Debug for AnthropicConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("AnthropicConfig")
.field("api_key", &"[REDACTED]")
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("max_tokens", &self.max_tokens)
.field("effort", &self.effort)
.field("thinking", &self.thinking)
.field("timeout", &self.timeout)
.field("retry", &self.retry)
.finish()
}
}
fn env_value(name: &str) -> Option<String> {
env::var(name).ok().filter(|v| !v.trim().is_empty())
}
fn missing_key() -> RunnerError {
RunnerError::config(format!(
"{ANTHROPIC_API_KEY_ENV} environment variable not set"
))
}
fn numbered_api_keys(lookup: impl Fn(&str) -> Option<String>) -> Vec<String> {
let mut keys = Vec::new();
for account in 1_usize.. {
let name = if account == 1 {
ANTHROPIC_API_KEY_ENV.to_owned()
} else {
format!("{ANTHROPIC_API_KEY_ENV}_{account}")
};
match lookup(&name).filter(|v| !v.trim().is_empty()) {
Some(key) => keys.push(key),
None => break,
}
}
keys
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
struct CacheControl {
#[serde(rename = "type")]
kind: &'static str,
}
const EPHEMERAL: CacheControl = CacheControl { kind: "ephemeral" };
#[derive(Debug, Serialize)]
struct MessagesRequest {
model: String,
max_tokens: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
system: Vec<SystemBlock>,
messages: Vec<WireMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<WireTool>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<WireToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
stop_sequences: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<WireThinking>,
#[serde(skip_serializing_if = "Option::is_none")]
output_config: Option<OutputConfig>,
cache_control: CacheControl,
#[serde(skip_serializing_if = "std::ops::Not::not")]
stream: bool,
}
#[derive(Debug, Serialize)]
struct SystemBlock {
#[serde(rename = "type")]
kind: &'static str,
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
}
#[derive(Debug, Serialize)]
struct WireMessage {
role: &'static str,
content: Vec<WireBlock>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WireBlock {
Text {
text: String,
},
Image {
source: ImageSource,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
content: String,
},
}
#[derive(Debug, Serialize)]
struct ImageSource {
#[serde(rename = "type")]
kind: &'static str,
media_type: String,
data: String,
}
#[derive(Debug, Serialize)]
struct WireTool {
name: String,
#[serde(skip_serializing_if = "String::is_empty")]
description: String,
input_schema: Value,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WireToolChoice {
Auto,
None,
Any,
Tool { name: String },
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WireThinking {
Adaptive,
Disabled,
}
#[derive(Debug, Serialize)]
struct OutputConfig {
effort: &'static str,
}
#[derive(Debug, Deserialize)]
struct MessagesResponse {
#[serde(default)]
content: Vec<ResponseBlock>,
#[serde(default)]
stop_reason: Option<String>,
#[serde(default)]
stop_details: Option<StopDetails>,
#[serde(default)]
usage: Option<WireUsage>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ResponseBlock {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
#[serde(default)]
input: Value,
},
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct StopDetails {
#[serde(default)]
category: Option<String>,
}
#[derive(Debug, Default, Clone, Copy, Deserialize)]
struct WireUsage {
#[serde(rename = "input_tokens", default)]
input: Option<u32>,
#[serde(rename = "output_tokens", default)]
output: Option<u32>,
#[serde(rename = "cache_read_input_tokens", default)]
cache_read: Option<u32>,
#[serde(rename = "cache_creation_input_tokens", default)]
cache_creation: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum StreamEvent {
ContentBlockDelta {
delta: StreamDelta,
},
MessageDelta {
delta: MessageDeltaBody,
},
Error {
error: ErrorBody,
},
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum StreamDelta {
TextDelta {
text: String,
},
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct MessageDeltaBody {
#[serde(default)]
stop_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ErrorEnvelope {
error: ErrorBody,
}
#[derive(Debug, Deserialize)]
struct ErrorBody {
#[serde(rename = "type", default)]
kind: String,
#[serde(default)]
message: String,
}
impl ErrorBody {
fn describe(&self) -> String {
format!("{} - {}", self.kind, self.message)
}
}
fn accepts_sampling(model: &str) -> bool {
has_prefix(model, SAMPLING_MODEL_PREFIXES)
}
fn rejects_forced_tool_choice(model: &str) -> bool {
has_prefix(model, FORCED_TOOL_CHOICE_REJECTED)
}
fn has_prefix(model: &str, prefixes: &[&str]) -> bool {
prefixes.iter().any(|p| model.starts_with(p))
}
fn wire_effort(config: &AnthropicConfig, model: &str) -> Option<OutputConfig> {
let effort = config.effort?;
if has_prefix(model, PRE_EFFORT_MODEL_PREFIXES) {
debug!(
model,
effort = effort.as_str(),
"omitting output_config.effort: the model predates it"
);
return None;
}
Some(OutputConfig {
effort: effort.as_str(),
})
}
fn wire_thinking(config: &AnthropicConfig, model: &str) -> Option<WireThinking> {
let refused = match config.thinking? {
AnthropicThinking::Adaptive => {
has_prefix(model, PRE_EFFORT_MODEL_PREFIXES)
|| has_prefix(model, PRE_ADAPTIVE_MODEL_PREFIXES)
}
AnthropicThinking::Disabled => {
has_prefix(model, THINKING_ALWAYS_ON)
|| (has_prefix(model, THINKING_OFF_UP_TO_HIGH)
&& matches!(
config.effort,
Some(AnthropicEffort::XHigh | AnthropicEffort::Max)
))
}
};
if refused {
debug!(
model,
thinking = ?config.thinking,
"omitting thinking: the model refuses this mode"
);
return None;
}
config.thinking.map(|thinking| match thinking {
AnthropicThinking::Adaptive => WireThinking::Adaptive,
AnthropicThinking::Disabled => WireThinking::Disabled,
})
}
fn empty_object() -> Value {
Value::Object(Map::new())
}
fn tool_input(arguments: &Value) -> Value {
match arguments {
Value::Object(_) => arguments.clone(),
Value::String(encoded) => serde_json::from_str::<Value>(encoded)
.ok()
.filter(Value::is_object)
.unwrap_or_else(empty_object),
_ => empty_object(),
}
}
fn text_block(text: &str) -> Option<WireBlock> {
(!text.trim().is_empty()).then(|| WireBlock::Text {
text: text.to_owned(),
})
}
fn convert_messages(messages: &[ChatMessage]) -> (Vec<SystemBlock>, Vec<WireMessage>) {
let mut system: Vec<SystemBlock> = Vec::new();
let mut turns: Vec<WireMessage> = Vec::new();
let mut open_calls: Vec<String> = Vec::new();
for message in messages {
match message.role {
MessageRole::System => {
if !message.content.trim().is_empty() {
system.push(SystemBlock {
kind: "text",
text: message.content.clone(),
cache_control: None,
});
}
}
MessageRole::User => {
let mut blocks: Vec<WireBlock> = message
.images
.iter()
.flatten()
.map(|image| WireBlock::Image {
source: ImageSource {
kind: "base64",
media_type: image.mime_type.clone(),
data: image.data.clone(),
},
})
.collect();
blocks.extend(text_block(&message.content));
push_turn(&mut turns, "user", blocks);
}
MessageRole::Assistant => {
if turns.is_empty() {
debug!(
"dropping a leading assistant turn: the first message must be the user's"
);
continue;
}
let calls = message.tool_calls.as_deref().unwrap_or_default();
let mut blocks: Vec<WireBlock> = text_block(&message.content).into_iter().collect();
blocks.extend(calls.iter().map(|call| WireBlock::ToolUse {
id: call.id.clone(),
name: call.function_name.clone(),
input: tool_input(&call.arguments),
}));
if blocks.is_empty() {
continue;
}
if turns.last().is_some_and(|t| t.role != "assistant") {
open_calls.clear();
}
open_calls.extend(calls.iter().map(|call| call.id.clone()));
push_turn(&mut turns, "assistant", blocks);
}
MessageRole::Tool => {
let block = match &message.tool_call_id {
Some(id) if open_calls.contains(id) => WireBlock::ToolResult {
tool_use_id: id.clone(),
content: message.content.clone(),
},
_ => WireBlock::Text {
text: format!(
"Result of tool {}: {}",
message.name.as_deref().unwrap_or("call"),
message.content
),
},
};
push_turn(&mut turns, "user", vec![block]);
}
}
}
if let Some(last) = system.last_mut() {
last.cache_control = Some(EPHEMERAL);
}
pair_tool_blocks(&mut turns);
for turn in &mut turns {
turn.content
.sort_by_key(|block| !matches!(block, WireBlock::ToolResult { .. }));
}
(system, turns)
}
fn pair_tool_blocks(turns: &mut [WireMessage]) {
for index in 0..turns.len() {
if turns[index].role != "user" {
continue;
}
let called = match index.checked_sub(1).map(|prev| &turns[prev]) {
Some(prev) if prev.role == "assistant" => block_ids(prev, ToolSide::Use),
_ => Vec::new(),
};
for block in &mut turns[index].content {
if let WireBlock::ToolResult {
tool_use_id,
content,
} = block
{
if !called.contains(tool_use_id) {
*block = WireBlock::Text {
text: format!("Result of tool call {tool_use_id}: {content}"),
};
}
}
}
}
for index in 0..turns.len() {
if turns[index].role != "assistant" {
continue;
}
let answered = match turns.get(index + 1) {
Some(next) if next.role == "user" => block_ids(next, ToolSide::Result),
_ => Vec::new(),
};
for block in &mut turns[index].content {
if let WireBlock::ToolUse { id, name, input } = block {
if !answered.contains(id) {
*block = WireBlock::Text {
text: format!("Called tool {name} with {input}; no result was recorded."),
};
}
}
}
}
}
#[derive(Clone, Copy)]
enum ToolSide {
Use,
Result,
}
fn block_ids(turn: &WireMessage, side: ToolSide) -> Vec<String> {
turn.content
.iter()
.filter_map(|block| match (side, block) {
(ToolSide::Use, WireBlock::ToolUse { id, .. })
| (
ToolSide::Result,
WireBlock::ToolResult {
tool_use_id: id, ..
},
) => Some(id.clone()),
_ => None,
})
.collect()
}
fn push_turn(turns: &mut Vec<WireMessage>, role: &'static str, blocks: Vec<WireBlock>) {
if blocks.is_empty() {
return;
}
match turns.last_mut() {
Some(last) if last.role == role => last.content.extend(blocks),
_ => turns.push(WireMessage {
role,
content: blocks,
}),
}
}
fn convert_tools(tools: &[ToolDefinition]) -> Vec<WireTool> {
let mut wire: Vec<WireTool> = tools
.iter()
.map(|tool| WireTool {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: tool
.parameters
.clone()
.unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}})),
cache_control: None,
})
.collect();
if let Some(last) = wire.last_mut() {
last.cache_control = Some(EPHEMERAL);
}
wire
}
fn convert_tool_choice(choice: Option<&ToolChoice>, model: &str) -> WireToolChoice {
match choice {
None | Some(ToolChoice::Auto) => WireToolChoice::Auto,
Some(ToolChoice::None) => WireToolChoice::None,
Some(ToolChoice::Required | ToolChoice::Specific { .. })
if rejects_forced_tool_choice(model) =>
{
WireToolChoice::Auto
}
Some(ToolChoice::Required) => WireToolChoice::Any,
Some(ToolChoice::Specific { name }) => WireToolChoice::Tool { name: name.clone() },
}
}
fn build_body(config: &AnthropicConfig, request: &ChatRequest, stream: bool) -> MessagesRequest {
let model = request
.model
.clone()
.unwrap_or_else(|| config.model.clone());
let (system, messages) = convert_messages(&request.messages);
let tools = request
.tools
.as_deref()
.map(convert_tools)
.unwrap_or_default();
let tool_choice =
(!tools.is_empty()).then(|| convert_tool_choice(request.tool_choice.as_ref(), &model));
let (temperature, top_p) = if accepts_sampling(&model) {
(
request.temperature,
request.top_p.filter(|_| request.temperature.is_none()),
)
} else {
(None, None)
};
let stop_sequences = request
.stop
.iter()
.flatten()
.filter(|s| !s.is_empty())
.cloned()
.collect();
MessagesRequest {
max_tokens: request.max_tokens.unwrap_or(config.max_tokens),
system,
messages,
tools,
tool_choice,
temperature,
top_p,
stop_sequences,
thinking: wire_thinking(config, &model),
output_config: wire_effort(config, &model),
cache_control: EPHEMERAL,
stream,
model,
}
}
fn map_stop_reason(reason: &str) -> String {
match reason {
"end_turn" | "stop_sequence" => "stop",
"max_tokens" | "model_context_window_exceeded" => "length",
"tool_use" => "tool_calls",
other => other,
}
.to_owned()
}
fn gross_usage(usage: WireUsage) -> TokenUsage {
let prompt = usage
.input
.unwrap_or(0)
.saturating_add(usage.cache_read.unwrap_or(0))
.saturating_add(usage.cache_creation.unwrap_or(0));
let output = usage.output.unwrap_or(0);
TokenUsage::new(prompt, output, prompt.saturating_add(output))
.with_cache(usage.cache_read, usage.cache_creation)
}
fn into_chat_response(response: MessagesResponse, model: &str) -> ChatResponse {
let mut content = String::new();
let mut tool_calls = Vec::new();
for block in response.content {
match block {
ResponseBlock::Text { text } => content.push_str(&text),
ResponseBlock::ToolUse { id, name, input } => tool_calls.push(ToolCallRequest {
id,
function_name: name,
arguments: input,
}),
ResponseBlock::Other => {}
}
}
if response.stop_reason.as_deref() == Some("refusal") {
warn!(
provider = PROVIDER_NAME,
model,
category = response
.stop_details
.as_ref()
.and_then(|d| d.category.as_deref())
.unwrap_or("unspecified"),
discarded_text_len = content.len(),
discarded_tool_calls = tool_calls.len(),
"the model declined the request (stop_reason refusal); partial output discarded"
);
content.clear();
tool_calls.clear();
}
ChatResponse {
content,
model: model.to_owned(),
usage: response.usage.map(gross_usage),
finish_reason: response.stop_reason.as_deref().map(map_stop_reason),
warnings: None,
tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
}
}
fn status_for_error_type(kind: &str) -> StatusCode {
match kind {
"invalid_request_error" => StatusCode::BAD_REQUEST,
"authentication_error" => StatusCode::UNAUTHORIZED,
"billing_error" => StatusCode::PAYMENT_REQUIRED,
"permission_error" => StatusCode::FORBIDDEN,
"not_found_error" => StatusCode::NOT_FOUND,
"request_too_large" => StatusCode::PAYLOAD_TOO_LARGE,
"rate_limit_error" => StatusCode::TOO_MANY_REQUESTS,
"overloaded_error" => overloaded_status(),
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn overloaded_status() -> StatusCode {
StatusCode::from_u16(529).unwrap_or(StatusCode::SERVICE_UNAVAILABLE)
}
fn map_error(
status: StatusCode,
body: Option<&ErrorBody>,
retry_after: Option<u64>,
model: &str,
) -> RunnerError {
let kind = body.map(|b| b.kind.as_str());
let message = body.map_or_else(|| format!("HTTP {status}"), ErrorBody::describe);
match (status.as_u16(), kind) {
(413, _) | (_, Some("request_too_large")) => {
RunnerError::context_length(format!("{PROVIDER_NAME}: {message}"))
}
(400, _) if message.to_lowercase().contains(PROMPT_TOO_LONG) => {
RunnerError::context_length(format!("{PROVIDER_NAME}: {message}"))
}
(404, _) | (_, Some("not_found_error")) => RunnerError::model_unavailable(model),
(402, _) | (_, Some("billing_error")) => {
client::map_http_error(PROVIDER_NAME, StatusCode::PAYMENT_REQUIRED, &message)
}
(429, _) | (_, Some("rate_limit_error")) => {
client::rate_limit_error(PROVIDER_NAME, &message, retry_after)
}
(529, _) | (_, Some("overloaded_error")) => {
RunnerError::external_service(PROVIDER_NAME, format!("HTTP 529 overloaded: {message}"))
}
_ => client::map_http_error(PROVIDER_NAME, status, &message),
}
}
fn attempt_error(status: StatusCode, error: RunnerError) -> AttemptError {
if error.kind == ErrorKind::RateLimit {
AttemptError::permanent(error)
} else {
AttemptError::from_status(status, error)
}
}
fn map_api_error(
status: StatusCode,
body: &str,
retry_after: Option<u64>,
model: &str,
) -> RunnerError {
let parsed = serde_json::from_str::<ErrorEnvelope>(body)
.ok()
.map(|e| e.error);
if parsed.is_none() {
debug!(
status = status.as_u16(),
body_preview = %body.chars().take(200).collect::<String>(),
"Anthropic API returned a non-JSON error response"
);
}
map_error(status, parsed.as_ref(), retry_after, model)
}
fn retry_after_header(headers: &HeaderMap) -> Option<u64> {
let seconds: f64 = headers
.get(RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(client::whole_seconds(seconds))
}
fn parse_stream_data(json: &str, model: &str) -> Option<Result<StreamChunk, RunnerError>> {
match serde_json::from_str::<StreamEvent>(json) {
Ok(StreamEvent::ContentBlockDelta {
delta: StreamDelta::TextDelta { text },
}) => Some(Ok(StreamChunk {
delta: text,
is_final: false,
finish_reason: None,
})),
Ok(StreamEvent::MessageDelta { delta }) => {
let reason = delta.stop_reason?;
if reason == "refusal" {
warn!(
provider = PROVIDER_NAME,
"the model declined the request mid-stream (stop_reason refusal)"
);
}
Some(Ok(StreamChunk {
delta: String::new(),
is_final: true,
finish_reason: Some(map_stop_reason(&reason)),
}))
}
Ok(StreamEvent::Error { error }) => Some(Err(map_error(
status_for_error_type(&error.kind),
Some(&error),
None,
model,
))),
Ok(StreamEvent::ContentBlockDelta { .. } | StreamEvent::Other) => None,
Err(e) => Some(Err(RunnerError::external_service(
PROVIDER_NAME,
format!("SSE parse error: {e}"),
))),
}
}
pub struct AnthropicProvider {
config: AnthropicConfig,
client: reqwest::Client,
available_models: Vec<String>,
}
impl AnthropicProvider {
pub fn new(config: AnthropicConfig) -> Result<Self, RunnerError> {
let client = client::build_client(config.timeout)?;
Ok(Self::with_client(config, client))
}
#[must_use]
pub fn with_client(config: AnthropicConfig, client: reqwest::Client) -> Self {
debug!(
model = %config.model,
max_tokens = config.max_tokens,
effort = config.effort.map(AnthropicEffort::as_str),
max_retries = config.retry.max_retries,
"Anthropic provider initialized"
);
Self {
config,
client,
available_models: AVAILABLE_MODELS.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[must_use]
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
self.config.model = model.into();
self
}
fn api_url(&self, path: &str) -> String {
format!("{}/v1/{path}", self.config.base_url.trim_end_matches('/'))
}
fn auth_headers(&self) -> Result<HeaderMap, RunnerError> {
let mut key = HeaderValue::from_str(&self.config.api_key).map_err(|_| {
RunnerError::config(format!(
"{ANTHROPIC_API_KEY_ENV} holds characters an HTTP header cannot carry"
))
})?;
key.set_sensitive(true);
let mut headers = HeaderMap::new();
headers.insert("x-api-key", key);
headers.insert("anthropic-version", HeaderValue::from_static(API_VERSION));
Ok(headers)
}
fn post_messages(&self, body: &MessagesRequest) -> Result<RequestBuilder, RunnerError> {
Ok(self
.client
.post(self.api_url("messages"))
.headers(self.auth_headers()?)
.header(CONTENT_TYPE, "application/json")
.json(body))
}
async fn attempt_complete(&self, body: &MessagesRequest) -> Result<ChatResponse, AttemptError> {
let response = self
.post_messages(body)
.map_err(AttemptError::permanent)?
.send()
.await
.map_err(|e| AttemptError::from_send(PROVIDER_NAME, e))?;
let status = response.status();
let retry_after = retry_after_header(response.headers());
let text = response.text().await.map_err(|e| {
AttemptError::transient(RunnerError::external_service(
PROVIDER_NAME,
format!("Failed to read response: {e}"),
))
})?;
if !status.is_success() {
return Err(attempt_error(
status,
map_api_error(status, &text, retry_after, &body.model),
));
}
let parsed: MessagesResponse = serde_json::from_str(&text).map_err(|e| {
AttemptError::permanent(RunnerError::external_service(
PROVIDER_NAME,
format!("Failed to parse response: {e}"),
))
})?;
Ok(into_chat_response(parsed, &body.model))
}
async fn attempt_stream(&self, body: &MessagesRequest) -> Result<ChatStream, AttemptError> {
let response = self
.post_messages(body)
.map_err(AttemptError::permanent)?
.send()
.await
.map_err(|e| AttemptError::from_send(PROVIDER_NAME, e))?;
let status = response.status();
if !status.is_success() {
let retry_after = retry_after_header(response.headers());
let text = response.text().await.unwrap_or_default();
return Err(attempt_error(
status,
map_api_error(status, &text, retry_after, &body.model),
));
}
let model = body.model.clone();
Ok(create_sse_stream(
response.bytes_stream(),
move |json| parse_stream_data(json, &model),
PROVIDER_NAME,
))
}
}
#[async_trait]
impl LlmProvider for AnthropicProvider {
fn name(&self) -> &'static str {
PROVIDER_NAME
}
fn display_name(&self) -> &str {
"Anthropic API"
}
fn capabilities(&self) -> LlmCapabilities {
let base = LlmCapabilities::STREAMING
| LlmCapabilities::SYSTEM_MESSAGES
| LlmCapabilities::FUNCTION_CALLING
| LlmCapabilities::VISION
| LlmCapabilities::MAX_TOKENS
| LlmCapabilities::STOP_SEQUENCES;
if accepts_sampling(&self.config.model) {
base | LlmCapabilities::TEMPERATURE | LlmCapabilities::TOP_P
} else {
base
}
}
fn default_model(&self) -> &str {
&self.config.model
}
fn available_models(&self) -> &[String] {
&self.available_models
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.model)))]
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
let body = build_body(&self.config, request, false);
let body = &body;
with_retries(
&self.config.retry,
PROVIDER_NAME,
"Anthropic request",
|attempt| async move {
debug!(attempt, "Sending request to the Anthropic Messages API");
self.attempt_complete(body).await
},
)
.await
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.model)))]
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
let body = build_body(&self.config, request, true);
let body = &body;
with_retries(
&self.config.retry,
PROVIDER_NAME,
"Anthropic streaming request",
|attempt| async move {
debug!(attempt, "Opening a stream on the Anthropic Messages API");
self.attempt_stream(body).await
},
)
.await
}
#[instrument(skip(self))]
async fn health_check(&self) -> Result<bool, RunnerError> {
let response = self
.client
.get(self.api_url(&format!("models/{}", self.config.model)))
.headers(self.auth_headers()?)
.send()
.await
.map_err(|e| client::map_send_error(PROVIDER_NAME, e))?;
Ok(response.status().is_success())
}
}
impl Debug for AnthropicProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("AnthropicProvider")
.field("default_model", &self.config.model)
.field("api_key", &"[REDACTED]")
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::io;
use serde_json::json;
use super::*;
use crate::fallback::is_empty_completion;
use crate::types::ImagePart;
fn body_json(config: &AnthropicConfig, request: &ChatRequest) -> Value {
let body = build_body(config, request, false);
serde_json::to_value(body).expect("serialises") }
fn call(id: &str, name: &str, arguments: Value) -> ToolCallRequest {
ToolCallRequest {
id: id.to_owned(),
function_name: name.to_owned(),
arguments,
}
}
fn assistant_calling(calls: Vec<ToolCallRequest>) -> ChatMessage {
ChatMessage {
tool_calls: Some(calls),
..ChatMessage::assistant("")
}
}
fn weather_tool(name: &str) -> ToolDefinition {
ToolDefinition {
name: name.to_owned(),
description: format!("{name} tool"),
parameters: Some(json!({"type": "object", "properties": {"city": {"type": "string"}}})),
}
}
#[test]
fn debug_redacts_the_api_key() {
let config = AnthropicConfig::new("sk-ant-super-secret");
assert!(!format!("{config:?}").contains("sk-ant-super-secret"));
let provider = AnthropicProvider::with_client(config, reqwest::Client::new());
let output = format!("{provider:?}");
assert!(!output.contains("sk-ant-super-secret"));
assert!(output.contains("[REDACTED]"));
}
#[test]
fn the_key_header_is_sensitive_and_carries_the_version() {
let provider = AnthropicProvider::with_client(
AnthropicConfig::new("sk-ant-super-secret"),
reqwest::Client::new(),
);
let headers = provider.auth_headers().expect("headers"); let key = headers.get("x-api-key").expect("key header"); assert!(key.is_sensitive());
assert!(!format!("{headers:?}").contains("sk-ant-super-secret"));
assert_eq!(
headers.get("anthropic-version").map(HeaderValue::as_bytes),
Some(&b"2023-06-01"[..])
);
}
#[test]
fn system_messages_are_hoisted_into_cached_blocks() {
let request = ChatRequest::new(vec![
ChatMessage::system("persona"),
ChatMessage::user("hi"),
ChatMessage::system("rules"),
]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["system"],
json!([
{"type": "text", "text": "persona"},
{"type": "text", "text": "rules", "cache_control": {"type": "ephemeral"}}
])
);
assert_eq!(
body["messages"],
json!([{"role": "user", "content": [{"type": "text", "text": "hi"}]}])
);
assert_eq!(body["cache_control"], json!({"type": "ephemeral"}));
assert_eq!(body["model"], "claude-haiku-5-5");
assert_eq!(body["max_tokens"], 16_000);
assert!(body.get("stream").is_none());
assert!(body.get("thinking").is_none());
assert!(body.get("output_config").is_none());
}
#[test]
fn no_system_and_no_tools_send_neither_field() {
let body = body_json(
&AnthropicConfig::new("k"),
&ChatRequest::new(vec![ChatMessage::user("hi")]),
);
assert!(body.get("system").is_none());
assert!(body.get("tools").is_none());
assert!(body.get("tool_choice").is_none());
}
#[test]
fn tool_use_and_tool_result_pair_and_parallel_results_merge() {
let request = ChatRequest::new(vec![
ChatMessage::user("weather in Paris and Lyon?"),
ChatMessage {
tool_calls: Some(vec![
call("toolu_1", "get_weather", json!({"city": "Paris"})),
call("toolu_2", "get_weather", json!("{\"city\":\"Lyon\"}")),
]),
..ChatMessage::assistant("Checking.")
},
ChatMessage::tool("get_weather", "toolu_1", "18C"),
ChatMessage::tool("get_weather", "toolu_2", "21C"),
]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["messages"],
json!([
{"role": "user", "content": [{"type": "text", "text": "weather in Paris and Lyon?"}]},
{"role": "assistant", "content": [
{"type": "text", "text": "Checking."},
{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Paris"}},
{"type": "tool_use", "id": "toolu_2", "name": "get_weather", "input": {"city": "Lyon"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "18C"},
{"type": "tool_result", "tool_use_id": "toolu_2", "content": "21C"}
]}
])
);
}
#[test]
fn a_tool_call_without_text_sends_only_tool_use_and_null_arguments_become_an_object() {
let request = ChatRequest::new(vec![
ChatMessage::user("go"),
assistant_calling(vec![call("toolu_1", "ping", Value::Null)]),
ChatMessage::tool("ping", "toolu_1", ""),
]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["messages"][1]["content"],
json!([{"type": "tool_use", "id": "toolu_1", "name": "ping", "input": {}}])
);
assert_eq!(
body["messages"][2]["content"],
json!([{"type": "tool_result", "tool_use_id": "toolu_1"}])
);
}
#[test]
fn same_role_turns_merge_with_tool_results_first() {
let request = ChatRequest::new(vec![
ChatMessage::user("go"),
assistant_calling(vec![call("toolu_1", "ping", json!({}))]),
ChatMessage::tool("ping", "toolu_1", "pong"),
ChatMessage::user("and now?"),
ChatMessage::assistant("first"),
ChatMessage::assistant("second"),
ChatMessage::user("ok"),
]);
let body = body_json(&AnthropicConfig::new("k"), &request);
let roles: Vec<&str> = body["messages"]
.as_array()
.expect("messages") .iter()
.map(|m| m["role"].as_str().unwrap_or_default())
.collect();
assert_eq!(roles, ["user", "assistant", "user", "assistant", "user"]);
assert_eq!(
body["messages"][2]["content"],
json!([
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "pong"},
{"type": "text", "text": "and now?"}
])
);
assert_eq!(
body["messages"][3]["content"].as_array().map(Vec::len),
Some(2)
);
}
#[test]
fn leading_assistant_turns_empty_turns_and_orphan_results_never_reach_the_wire() {
let request = ChatRequest::new(vec![
ChatMessage::assistant("Welcome back!"),
ChatMessage::tool("lookup", "toolu_gone", "stale"),
ChatMessage::user(" "),
ChatMessage::user("hello"),
ChatMessage::assistant(""),
]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["messages"],
json!([{"role": "user", "content": [
{"type": "text", "text": "Result of tool lookup: stale"},
{"type": "text", "text": "hello"}
]}])
);
}
#[test]
fn images_travel_as_base64_blocks_before_the_text() {
let image = ImagePart::new("aGVsbG8=", "image/png").expect("valid mime"); let request = ChatRequest::new(vec![ChatMessage::user_with_images(
"what is this?",
vec![image],
)]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["messages"][0]["content"],
json!([
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}},
{"type": "text", "text": "what is this?"}
])
);
}
#[test]
fn the_last_tool_carries_the_cache_breakpoint_and_the_body_is_deterministic() {
let request = ChatRequest::new(vec![
ChatMessage::system("persona"),
ChatMessage::user("hi"),
])
.with_tools(vec![
weather_tool("a"),
weather_tool("b"),
weather_tool("c"),
]);
let config = AnthropicConfig::new("k");
let body = body_json(&config, &request);
let tools = body["tools"].as_array().expect("tools"); let names: Vec<&str> = tools
.iter()
.map(|t| t["name"].as_str().unwrap_or_default())
.collect();
assert_eq!(names, ["a", "b", "c"]);
assert!(tools[0].get("cache_control").is_none());
assert!(tools[1].get("cache_control").is_none());
assert_eq!(tools[2]["cache_control"], json!({"type": "ephemeral"}));
assert_eq!(
tools[0]["input_schema"]["properties"]["city"]["type"],
"string"
);
assert_eq!(body["tool_choice"], json!({"type": "auto"}));
let breakpoints = serde_json::to_string(&body)
.expect("serialises") .matches("\"cache_control\"")
.count();
assert_eq!(
breakpoints, 3,
"tools, system, and the automatic tail: within the limit of 4"
);
let first = serde_json::to_vec(&build_body(&config, &request, false)).expect("serialises"); let second = serde_json::to_vec(&build_body(&config, &request, false)).expect("serialises"); assert_eq!(
first, second,
"the cached prefix must be byte-identical between calls"
);
}
#[test]
fn a_tool_without_parameters_gets_an_empty_object_schema() {
let request =
ChatRequest::new(vec![ChatMessage::user("hi")]).with_tools(vec![ToolDefinition {
name: "now".to_owned(),
description: String::new(),
parameters: None,
}]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["tools"][0]["input_schema"],
json!({"type": "object", "properties": {}})
);
assert!(body["tools"][0].get("description").is_none());
}
#[test]
fn forced_tool_choice_is_kept_where_accepted_and_downgraded_where_refused() {
let tools = vec![weather_tool("a")];
let base = ChatRequest::new(vec![ChatMessage::user("hi")]).with_tools(tools);
let config = AnthropicConfig::new("k");
let required = base.clone().with_tool_choice(ToolChoice::Required);
assert_eq!(
body_json(&config, &required)["tool_choice"],
json!({"type": "any"})
);
let specific = base.clone().with_tool_choice(ToolChoice::Specific {
name: "a".to_owned(),
});
assert_eq!(
body_json(&config, &specific)["tool_choice"],
json!({"type": "tool", "name": "a"})
);
let none = base.with_tool_choice(ToolChoice::None);
assert_eq!(
body_json(&config, &none)["tool_choice"],
json!({"type": "none"})
);
for model in ["claude-opus-5-5", "claude-sonnet-5-5"] {
let forced = required.clone().with_model(model);
assert_eq!(
body_json(&config, &forced)["tool_choice"],
json!({"type": "auto"}),
"{model}"
);
}
}
#[test]
fn sampling_parameters_reach_only_models_that_accept_them() {
let request = ChatRequest::new(vec![ChatMessage::user("hi")])
.with_temperature(0.2)
.with_top_p(0.5)
.with_stop(vec!["END".to_owned(), String::new()]);
let config = AnthropicConfig::new("k");
let haiku = body_json(&config, &request);
assert!(haiku.get("temperature").is_none());
assert!(haiku.get("top_p").is_none());
assert_eq!(haiku["stop_sequences"], json!(["END"]));
let legacy = body_json(&config, &request.with_model("claude-haiku-4-5"));
assert!((legacy["temperature"].as_f64().unwrap_or_default() - 0.2).abs() < 1e-6);
assert!(
legacy.get("top_p").is_none(),
"Claude 4 refuses temperature and top_p together"
);
let top_p_only = ChatRequest::new(vec![ChatMessage::user("hi")])
.with_top_p(0.5)
.with_model("claude-sonnet-4-6");
assert!(body_json(&config, &top_p_only).get("top_p").is_some());
let provider = AnthropicProvider::with_client(config, reqwest::Client::new());
assert!(!provider.capabilities().supports_temperature());
let legacy_provider = provider.with_default_model("claude-sonnet-4-5");
assert!(legacy_provider.capabilities().supports_temperature());
}
#[test]
fn effort_thinking_and_max_tokens_come_from_config_or_request() {
let config = AnthropicConfig::new("k")
.with_effort(AnthropicEffort::Low)
.with_thinking(AnthropicThinking::Disabled)
.with_max_tokens(2048);
let body = body_json(&config, &ChatRequest::new(vec![ChatMessage::user("hi")]));
assert_eq!(body["output_config"], json!({"effort": "low"}));
assert_eq!(body["thinking"], json!({"type": "disabled"}));
assert_eq!(body["max_tokens"], 2048);
let capped = body_json(
&config,
&ChatRequest::new(vec![ChatMessage::user("hi")]).with_max_tokens(64),
);
assert_eq!(capped["max_tokens"], 64);
let streamed = serde_json::to_value(build_body(&config, &ChatRequest::new(vec![]), true))
.expect("serialises"); assert_eq!(streamed["stream"], true);
}
#[test]
fn a_response_maps_text_tool_use_usage_and_stop_reason() {
let body = r#"{"id":"msg_1","type":"message","role":"assistant","model":"claude-haiku-5-5",
"content":[
{"type":"thinking","thinking":"","signature":"sig"},
{"type":"text","text":"Let me "},
{"type":"text","text":"check."},
{"type":"tool_use","id":"toolu_9","name":"get_weather","input":{"city":"Paris"}}
],
"stop_reason":"tool_use",
"usage":{"input_tokens":12,"output_tokens":40,"cache_read_input_tokens":3000,"cache_creation_input_tokens":500}}"#;
let parsed: MessagesResponse = serde_json::from_str(body).expect("parses"); let response = into_chat_response(parsed, "claude-haiku-5-5");
assert_eq!(response.content, "Let me check.");
assert_eq!(response.finish_reason.as_deref(), Some("tool_calls"));
let calls = response.tool_calls.expect("tool calls"); assert_eq!(calls[0].id, "toolu_9");
assert_eq!(calls[0].function_name, "get_weather");
assert_eq!(calls[0].arguments["city"], "Paris");
let usage = response.usage.expect("usage"); assert_eq!(
usage.prompt_tokens, 3512,
"gross: fresh + cache read + cache write"
);
assert_eq!(usage.completion_tokens, 40);
assert_eq!(usage.total_tokens, 3552);
assert_eq!(usage.cached_read_tokens, Some(3000));
assert_eq!(usage.cached_write_tokens, Some(500));
assert_eq!(usage.reasoning_tokens, None);
}
#[test]
fn stop_reasons_map_to_the_crate_vocabulary() {
let cases = [
("end_turn", "stop"),
("stop_sequence", "stop"),
("max_tokens", "length"),
("model_context_window_exceeded", "length"),
("tool_use", "tool_calls"),
("refusal", "refusal"),
("pause_turn", "pause_turn"),
];
for (wire, mapped) in cases {
assert_eq!(map_stop_reason(wire), mapped, "{wire}");
}
}
#[test]
fn an_empty_refusal_is_an_empty_completion_marked_refusal() {
let body = r#"{"content":[],"stop_reason":"refusal","stop_details":{"category":"cyber"},
"usage":{"input_tokens":20,"output_tokens":0}}"#;
let parsed: MessagesResponse = serde_json::from_str(body).expect("parses"); let response = into_chat_response(parsed, "claude-haiku-5-5");
assert_eq!(response.finish_reason.as_deref(), Some("refusal"));
assert!(response.tool_calls.is_none());
assert!(
is_empty_completion(&response),
"a strict chain moves past it"
);
}
#[test]
fn a_refusal_after_partial_output_discards_it() {
let body = r#"{"content":[
{"type":"text","text":"Here is how you would start: first"},
{"type":"tool_use","id":"toolu_1","name":"search","input":{}}
],
"stop_reason":"refusal","stop_details":{"category":"general_harms"},
"usage":{"input_tokens":20,"output_tokens":9}}"#;
let parsed: MessagesResponse = serde_json::from_str(body).expect("parses"); let response = into_chat_response(parsed, "claude-haiku-5-5");
assert_eq!(response.content, "", "the partial answer is discarded");
assert!(response.tool_calls.is_none());
assert_eq!(response.finish_reason.as_deref(), Some("refusal"));
assert_eq!(
response.usage.as_ref().map(|u| u.completion_tokens),
Some(9),
"the billed usage is kept"
);
assert!(
is_empty_completion(&response),
"a strict chain moves past it"
);
}
#[test]
fn a_result_for_another_call_leaves_neither_half_dangling() {
let request = ChatRequest::new(vec![
ChatMessage::user("weather?"),
assistant_calling(vec![call(
"toolu_x",
"get_weather",
json!({"city": "Paris"}),
)]),
ChatMessage::tool("get_weather", "toolu_y", "sunny"),
]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["messages"],
json!([
{"role": "user", "content": [{"type": "text", "text": "weather?"}]},
{"role": "assistant", "content": [{"type": "text",
"text": "Called tool get_weather with {\"city\":\"Paris\"}; no result was recorded."}]},
{"role": "user", "content": [{"type": "text", "text": "Result of tool get_weather: sunny"}]}
])
);
}
#[test]
fn an_unanswered_call_becomes_text_and_its_answered_sibling_stays_paired() {
let request = ChatRequest::new(vec![
ChatMessage::user("plan"),
assistant_calling(vec![
call("toolu_a", "load", json!({})),
call("toolu_b", "fit", json!({})),
]),
ChatMessage::tool("load", "toolu_a", "42"),
ChatMessage::user("go on"),
]);
let body = body_json(&AnthropicConfig::new("k"), &request);
assert_eq!(
body["messages"][1]["content"],
json!([
{"type": "tool_use", "id": "toolu_a", "name": "load", "input": {}},
{"type": "text", "text": "Called tool fit with {}; no result was recorded."}
])
);
assert_eq!(
body["messages"][2]["content"],
json!([
{"type": "tool_result", "tool_use_id": "toolu_a", "content": "42"},
{"type": "text", "text": "go on"}
])
);
let trailing = body_json(
&AnthropicConfig::new("k"),
&ChatRequest::new(vec![
ChatMessage::user("plan"),
assistant_calling(vec![call("toolu_c", "load", json!({}))]),
]),
);
assert_eq!(
trailing["messages"][1]["content"][0]["type"], "text",
"a call with no turn after it has no result either"
);
}
#[test]
fn thinking_and_effort_reach_only_models_that_accept_them() {
let body_for = |config: &AnthropicConfig, model: &str| {
body_json(
config,
&ChatRequest::new(vec![ChatMessage::user("hi")]).with_model(model),
)
};
let off_high = AnthropicConfig::new("k")
.with_thinking(AnthropicThinking::Disabled)
.with_effort(AnthropicEffort::High);
let haiku = body_for(&off_high, "claude-haiku-5-5");
assert_eq!(haiku["thinking"], json!({"type": "disabled"}));
assert_eq!(haiku["output_config"], json!({"effort": "high"}));
for always_on in ["claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1"] {
let body = body_for(&off_high, always_on);
assert!(body.get("thinking").is_none(), "{always_on}");
assert_eq!(
body["output_config"],
json!({"effort": "high"}),
"{always_on}"
);
}
let off_max = AnthropicConfig::new("k")
.with_thinking(AnthropicThinking::Disabled)
.with_effort(AnthropicEffort::Max);
for model in ["claude-haiku-5-5", "claude-opus-5"] {
let body = body_for(&off_max, model);
assert!(body.get("thinking").is_none(), "{model}");
assert_eq!(body["output_config"], json!({"effort": "max"}), "{model}");
}
let adaptive_low = AnthropicConfig::new("k")
.with_thinking(AnthropicThinking::Adaptive)
.with_effort(AnthropicEffort::Low);
for legacy in [
"claude-haiku-4-5",
"claude-sonnet-4-5",
"claude-sonnet-4-20250514",
"claude-3-5-haiku-latest",
] {
let body = body_for(&adaptive_low, legacy);
assert!(body.get("thinking").is_none(), "{legacy}");
assert!(body.get("output_config").is_none(), "{legacy}");
}
let opus_4_5 = body_for(&adaptive_low, "claude-opus-4-5");
assert!(opus_4_5.get("thinking").is_none());
assert_eq!(opus_4_5["output_config"], json!({"effort": "low"}));
let opus_4_6 = body_for(&adaptive_low, "claude-opus-4-6");
assert_eq!(opus_4_6["thinking"], json!({"type": "adaptive"}));
assert_eq!(opus_4_6["output_config"], json!({"effort": "low"}));
}
#[test]
fn a_quota_refusal_leaves_the_tier_and_an_overload_is_retried() {
let rate_limited = map_api_error(
StatusCode::TOO_MANY_REQUESTS,
&error_body("rate_limit_error", "slow down"),
Some(3),
"m",
);
assert!(!attempt_error(StatusCode::TOO_MANY_REQUESTS, rate_limited).retryable);
let spent = map_api_error(
StatusCode::PAYMENT_REQUIRED,
&error_body("billing_error", "Your credit balance is too low"),
None,
"m",
);
assert!(!attempt_error(StatusCode::PAYMENT_REQUIRED, spent).retryable);
let typed = map_api_error(
StatusCode::SERVICE_UNAVAILABLE,
&error_body("rate_limit_error", "slow down"),
None,
"m",
);
assert_eq!(typed.kind, ErrorKind::RateLimit);
assert!(!attempt_error(StatusCode::SERVICE_UNAVAILABLE, typed).retryable);
let overloaded = map_api_error(
overloaded_status(),
&error_body("overloaded_error", "Overloaded"),
None,
"m",
);
assert!(attempt_error(overloaded_status(), overloaded).retryable);
}
fn error_body(kind: &str, message: &str) -> String {
json!({"type": "error", "error": {"type": kind, "message": message}}).to_string()
}
#[test]
fn a_402_spent_credit_is_a_quota_refusal_not_retried_in_place() {
let err = map_api_error(
StatusCode::PAYMENT_REQUIRED,
&error_body("billing_error", "Your credit balance is too low"),
None,
"claude-haiku-5-5",
);
assert_eq!(err.kind, ErrorKind::RateLimit);
assert!(err.message.starts_with("anthropic: "), "{}", err.message);
assert!(
err.message.contains("credit balance exhausted"),
"{}",
err.message
);
assert!(!client::is_retryable_status(402));
}
#[test]
fn a_429_carries_the_retry_after_wait() {
let err = map_api_error(
StatusCode::TOO_MANY_REQUESTS,
&error_body(
"rate_limit_error",
"Number of request tokens has exceeded your rate limit",
),
Some(12),
"claude-haiku-5-5",
);
assert_eq!(err.kind, ErrorKind::RateLimit);
assert!(
err.message.contains("try again in 12 seconds"),
"{}",
err.message
);
let mut headers = HeaderMap::new();
headers.insert(RETRY_AFTER, HeaderValue::from_static("7"));
assert_eq!(retry_after_header(&headers), Some(7));
headers.insert(RETRY_AFTER, HeaderValue::from_static("0.2"));
assert_eq!(retry_after_header(&headers), Some(1));
assert_eq!(retry_after_header(&HeaderMap::new()), None);
}
#[test]
fn a_529_is_a_transient_external_service_failure() {
let status = overloaded_status();
assert_eq!(status.as_u16(), 529);
let err = map_api_error(
status,
&error_body("overloaded_error", "Overloaded"),
None,
"m",
);
assert_eq!(err.kind, ErrorKind::ExternalService);
assert!(err.kind.is_transient());
assert!(client::is_retryable_status(529));
}
#[test]
fn auth_validation_size_and_model_errors_map_to_their_kinds() {
let auth = map_api_error(
StatusCode::UNAUTHORIZED,
&error_body("authentication_error", "invalid x-api-key"),
None,
"m",
);
assert_eq!(auth.kind, ErrorKind::AuthFailure);
let forbidden = map_api_error(
StatusCode::FORBIDDEN,
&error_body("permission_error", "no"),
None,
"m",
);
assert_eq!(forbidden.kind, ErrorKind::AuthFailure);
let invalid = map_api_error(
StatusCode::BAD_REQUEST,
&error_body("invalid_request_error", "messages: roles must alternate"),
None,
"m",
);
assert_eq!(invalid.kind, ErrorKind::InvalidRequest);
assert!(invalid.message.contains("roles must alternate"));
let too_long = map_api_error(
StatusCode::BAD_REQUEST,
&error_body(
"invalid_request_error",
"prompt is too long: 1200000 tokens > 1000000 maximum",
),
None,
"m",
);
assert_eq!(too_long.kind, ErrorKind::ContextLength);
let too_large = map_api_error(StatusCode::PAYLOAD_TOO_LARGE, "<html>", None, "m");
assert_eq!(too_large.kind, ErrorKind::ContextLength);
let missing = map_api_error(
StatusCode::NOT_FOUND,
&error_body("not_found_error", "model: x"),
None,
"claude-x",
);
assert_eq!(missing.kind, ErrorKind::ModelUnavailable);
let opaque = map_api_error(StatusCode::INTERNAL_SERVER_ERROR, "<html>", None, "m");
assert_eq!(opaque.kind, ErrorKind::ExternalService);
assert!(opaque.message.contains("HTTP 500"));
}
#[test]
fn no_error_message_carries_the_api_key() {
let key = "sk-ant-api03-very-secret";
let provider =
AnthropicProvider::with_client(AnthropicConfig::new(key), reqwest::Client::new());
for status in [401_u16, 402, 403, 404, 413, 429, 500, 529] {
let status = StatusCode::from_u16(status).expect("valid status"); let err = map_api_error(
status,
&error_body("x", "refused"),
Some(3),
&provider.config.model,
);
assert!(!err.message.contains(key), "{status}: {}", err.message);
}
let invalid_header = AnthropicProvider::with_client(
AnthropicConfig::new("bad\nkey-secret"),
reqwest::Client::new(),
);
let err = invalid_header
.auth_headers()
.expect_err("a newline cannot ride in a header"); assert_eq!(err.kind, ErrorKind::Config);
assert!(!err.message.contains("key-secret"));
}
#[test]
fn a_canned_event_transcript_streams_text_then_the_stop() {
let transcript = [
r#"{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":1}}}"#,
r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"abc"}}"#,
r#"{"type":"content_block_stop","index":0}"#,
r#"{"type":"ping"}"#,
r#"{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#,
r#"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}}"#,
r#"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" world"}}"#,
r#"{"type":"content_block_stop","index":1}"#,
r#"{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_1","name":"f","input":{}}}"#,
r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"a\":"}}"#,
r#"{"type":"content_block_stop","index":2}"#,
r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#,
r#"{"type":"message_stop"}"#,
];
let chunks: Vec<StreamChunk> = transcript
.iter()
.filter_map(|frame| parse_stream_data(frame, "claude-haiku-5-5"))
.map(|chunk| chunk.expect("no error in the transcript")) .collect();
let deltas: Vec<&str> = chunks.iter().map(|c| c.delta.as_str()).collect();
assert_eq!(deltas, ["Hello", " world", ""]);
let last = chunks.last().expect("a final chunk"); assert!(last.is_final);
assert_eq!(last.finish_reason.as_deref(), Some("stop"));
}
#[tokio::test]
async fn the_stream_parser_runs_over_raw_sse_bytes() {
use tokio_stream::StreamExt;
let raw = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n\
event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"}}\n\n\
event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
let bytes = tokio_stream::iter(vec![Ok::<_, io::Error>(raw.as_bytes().to_vec())]);
let chunks: Vec<StreamChunk> = create_sse_stream(
bytes,
|json| parse_stream_data(json, "claude-haiku-5-5"),
PROVIDER_NAME,
)
.map(|c| c.expect("ok")) .collect()
.await;
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].delta, "Hi");
assert_eq!(chunks[1].finish_reason.as_deref(), Some("length"));
}
#[test]
fn a_mid_stream_error_event_ends_the_stream_with_its_kind() {
let overloaded = parse_stream_data(
r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
"claude-haiku-5-5",
)
.expect("an item") .expect_err("an error"); assert_eq!(overloaded.kind, ErrorKind::ExternalService);
let billing = parse_stream_data(
r#"{"type":"error","error":{"type":"billing_error","message":"credit"}}"#,
"claude-haiku-5-5",
)
.expect("an item") .expect_err("an error"); assert_eq!(billing.kind, ErrorKind::RateLimit);
let refusal = parse_stream_data(
r#"{"type":"message_delta","delta":{"stop_reason":"refusal"}}"#,
"claude-haiku-5-5",
)
.expect("an item") .expect("a final chunk"); assert_eq!(refusal.finish_reason.as_deref(), Some("refusal"));
assert!(parse_stream_data("not json", "claude-haiku-5-5").is_some_and(|r| r.is_err()));
let missing = parse_stream_data(
r#"{"type":"error","error":{"type":"not_found_error","message":"model: claude-x"}}"#,
"claude-x",
)
.expect("an item") .expect_err("an error"); assert_eq!(missing.kind, ErrorKind::ModelUnavailable);
assert_eq!(
missing.message, "Model \"claude-x\" is not available",
"a mid-stream not-found names the model the stream was opened for"
);
}
#[test]
fn numbered_keys_are_read_until_the_first_gap() {
let env: HashMap<&str, &str> = HashMap::from([
("ANTHROPIC_API_KEY", "key-1"),
("ANTHROPIC_API_KEY_2", "key-2"),
("ANTHROPIC_API_KEY_3", "key-3"),
("ANTHROPIC_API_KEY_5", "key-5"),
]);
let keys = numbered_api_keys(|name| env.get(name).map(|v| (*v).to_owned()));
assert_eq!(keys, ["key-1", "key-2", "key-3"]);
let blank: HashMap<&str, &str> = HashMap::from([
("ANTHROPIC_API_KEY", "key-1"),
("ANTHROPIC_API_KEY_2", " "),
("ANTHROPIC_API_KEY_3", "key-3"),
]);
let keys = numbered_api_keys(|name| blank.get(name).map(|v| (*v).to_owned()));
assert_eq!(keys, ["key-1"]);
assert!(numbered_api_keys(|_| None).is_empty());
}
#[test]
fn effort_and_thinking_parse_their_wire_values() {
assert_eq!(
AnthropicEffort::parse("XHigh"),
Some(AnthropicEffort::XHigh)
);
assert_eq!(
AnthropicEffort::parse("medium"),
Some(AnthropicEffort::Medium)
);
assert_eq!(AnthropicEffort::parse("extreme"), None);
assert_eq!(
AnthropicThinking::parse(" adaptive "),
Some(AnthropicThinking::Adaptive)
);
assert_eq!(AnthropicThinking::parse("enabled"), None);
}
mod env_tests {
use std::sync::{Mutex, PoisonError};
use super::*;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
const VARS: &[&str] = &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_2",
"ANTHROPIC_API_KEY_3",
"ANTHROPIC_DEFAULT_MODEL",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_MAX_TOKENS",
"ANTHROPIC_EFFORT",
"ANTHROPIC_THINKING",
"ANTHROPIC_MAX_RETRIES",
];
fn with_env<T>(values: &[(&str, &str)], f: impl FnOnce() -> T) -> T {
let guard = ENV_MUTEX.lock().unwrap_or_else(PoisonError::into_inner);
let saved: Vec<(&str, Option<String>)> = VARS
.iter()
.map(|name| (*name, env::var(name).ok()))
.collect();
for name in VARS {
env::remove_var(name);
}
for (name, value) in values {
env::set_var(name, value);
}
let result = f();
for (name, value) in saved {
match value {
Some(v) => env::set_var(name, v),
None => env::remove_var(name),
}
}
drop(guard);
result
}
#[test]
fn a_missing_key_is_a_config_error() {
let err = with_env(&[], AnthropicConfig::from_env).expect_err("no key"); assert_eq!(err.kind, ErrorKind::Config);
let err = with_env(&[], AnthropicConfig::pool_from_env).expect_err("no key"); assert_eq!(err.kind, ErrorKind::Config);
}
#[test]
fn from_env_reads_every_setting() {
let config = with_env(
&[
("ANTHROPIC_API_KEY", "key-1"),
("ANTHROPIC_DEFAULT_MODEL", "claude-sonnet-5-5"),
("ANTHROPIC_BASE_URL", "https://proxy.example"),
("ANTHROPIC_MAX_TOKENS", "4096"),
("ANTHROPIC_EFFORT", "high"),
("ANTHROPIC_THINKING", "adaptive"),
("ANTHROPIC_MAX_RETRIES", "1"),
],
AnthropicConfig::from_env,
)
.expect("valid env"); assert_eq!(config.api_key, "key-1");
assert_eq!(config.model, "claude-sonnet-5-5");
assert_eq!(config.base_url, "https://proxy.example");
assert_eq!(config.max_tokens, 4096);
assert_eq!(config.effort, Some(AnthropicEffort::High));
assert_eq!(config.thinking, Some(AnthropicThinking::Adaptive));
assert_eq!(config.retry.max_retries, 1);
}
#[test]
fn invalid_values_are_config_errors() {
for bad in [
&[("ANTHROPIC_API_KEY", "k"), ("ANTHROPIC_EFFORT", "extreme")][..],
&[
("ANTHROPIC_API_KEY", "k"),
("ANTHROPIC_THINKING", "enabled"),
][..],
&[("ANTHROPIC_API_KEY", "k"), ("ANTHROPIC_MAX_TOKENS", "0")][..],
&[
("ANTHROPIC_API_KEY", "k"),
("ANTHROPIC_THINKING", "disabled"),
("ANTHROPIC_EFFORT", "max"),
][..],
] {
let err = with_env(bad, AnthropicConfig::from_env).expect_err("invalid"); assert_eq!(err.kind, ErrorKind::Config, "{bad:?}");
}
}
#[test]
fn pool_from_env_shares_every_setting_but_the_key() {
let configs = with_env(
&[
("ANTHROPIC_API_KEY", "key-1"),
("ANTHROPIC_API_KEY_2", "key-2"),
("ANTHROPIC_EFFORT", "low"),
],
AnthropicConfig::pool_from_env,
)
.expect("valid env"); let keys: Vec<&str> = configs.iter().map(|c| c.api_key.as_str()).collect();
assert_eq!(keys, ["key-1", "key-2"]);
assert!(configs
.iter()
.all(|c| c.effort == Some(AnthropicEffort::Low)));
assert!(configs.iter().all(|c| c.model == "claude-haiku-5-5"));
}
}
}