use std::borrow::Cow;
use serde_json::{Value, json};
use crate::error::{LiterLlmError, Result};
use crate::provider::Provider;
use crate::types::{ChatCompletionChunk, FinishReason, StreamChoice, StreamDelta, StreamFunctionCall, StreamToolCall};
static ANTHROPIC_EXTRA_HEADERS: &[(&str, &str)] = &[("anthropic-version", "2023-06-01")];
const DEFAULT_MAX_TOKENS: u64 = 4096;
const HOSTED_TOOL_TYPES: &[&str] = &[
"computer_20241022",
"computer_use_20250124",
"web_search_20250305",
"code_execution_20250522",
];
const BETA_COMPUTER_USE: &str = "computer-use-2025-01-24";
const BETA_WEB_SEARCH: &str = "web-search-2025-03-05";
const BETA_CODE_EXECUTION: &str = "code-execution-2025-05-22";
const BETA_THINKING: &str = "thinking-2025-04-14";
const BETA_PROMPT_CACHING: &str = "prompt-caching-2024-07-31";
const BETA_PDFS: &str = "pdfs-2024-09-25";
pub struct AnthropicProvider;
impl Provider for AnthropicProvider {
fn name(&self) -> &str {
"anthropic"
}
fn base_url(&self) -> &str {
"https://api.anthropic.com/v1"
}
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key)))
}
fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
ANTHROPIC_EXTRA_HEADERS
}
fn dynamic_headers(&self, body: &serde_json::Value) -> Vec<(String, String)> {
let mut betas: Vec<&str> = Vec::new();
if body.get("thinking").is_some() {
betas.push(BETA_THINKING);
}
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
for tool in tools {
let tool_type = tool.get("type").and_then(|t| t.as_str()).unwrap_or("");
match tool_type {
"computer_20241022" | "computer_use_20250124" if !betas.contains(&BETA_COMPUTER_USE) => {
betas.push(BETA_COMPUTER_USE);
}
"web_search_20250305" if !betas.contains(&BETA_WEB_SEARCH) => {
betas.push(BETA_WEB_SEARCH);
}
"code_execution_20250522" if !betas.contains(&BETA_CODE_EXECUTION) => {
betas.push(BETA_CODE_EXECUTION);
}
_ => {}
}
}
}
if body_contains_cache_control(body) && !betas.contains(&BETA_PROMPT_CACHING) {
betas.push(BETA_PROMPT_CACHING);
}
if body_contains_document_block(body) && !betas.contains(&BETA_PDFS) {
betas.push(BETA_PDFS);
}
if betas.is_empty() {
vec![]
} else {
vec![("anthropic-beta".to_owned(), betas.join(","))]
}
}
fn matches_model(&self, model: &str) -> bool {
model.starts_with("claude-") || model.starts_with("anthropic/")
}
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
model.strip_prefix("anthropic/").unwrap_or(model)
}
fn chat_completions_path(&self) -> &str {
"/messages"
}
fn transform_request(&self, body: &mut Value) -> Result<()> {
let messages = body
.as_object_mut()
.and_then(|o| o.remove("messages"))
.and_then(|v| match v {
Value::Array(arr) => Some(arr),
_ => None,
})
.unwrap_or_default();
if messages.is_empty() {
return Err(LiterLlmError::BadRequest {
message: "messages array must not be empty".to_owned(),
});
}
let mut system_blocks: Vec<Value> = Vec::new();
let mut non_system_messages: Vec<Value> = Vec::new();
for msg in messages {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
match role {
"system" | "developer" => {
match msg.get("content") {
Some(Value::String(s)) if !s.is_empty() => {
let mut block = json!({"type": "text", "text": s});
if let Some(cc) = msg.get("cache_control") {
block["cache_control"] = cc.clone();
}
system_blocks.push(block);
}
Some(Value::Array(parts)) => {
for part in parts {
system_blocks.push(part.clone());
}
}
_ => {}
}
}
_ => non_system_messages.push(msg),
}
}
if !system_blocks.is_empty() {
body["system"] = json!(system_blocks);
}
let converted_messages: Vec<Value> = non_system_messages
.into_iter()
.map(convert_message_to_anthropic)
.collect();
let merged_messages = merge_consecutive_same_role(converted_messages);
body["messages"] = json!(merged_messages);
if body.get("max_tokens").is_none() {
if let Some(mct) = body.get("max_completion_tokens").cloned() {
body["max_tokens"] = mct;
} else {
body["max_tokens"] = json!(DEFAULT_MAX_TOKENS);
}
}
body.as_object_mut().map(|o| o.remove("max_completion_tokens"));
if let Some(stop) = body.as_object_mut().and_then(|o| o.remove("stop")) {
let stop_sequences = match stop {
Value::String(s) => json!([s]),
arr @ Value::Array(_) => arr,
_ => json!([]),
};
body["stop_sequences"] = stop_sequences;
}
if let Some(tool_choice) = body.as_object_mut().and_then(|o| o.remove("tool_choice")) {
let anthropic_tool_choice = convert_tool_choice(&tool_choice);
match anthropic_tool_choice {
Some(tc) => {
body["tool_choice"] = tc;
}
None => {
body.as_object_mut().map(|o| o.remove("tools"));
}
}
}
if let Some(tools) = body.as_object_mut().and_then(|o| o.remove("tools"))
&& let Some(tools_array) = tools.as_array()
{
let anthropic_tools: Vec<Value> = tools_array
.iter()
.map(|tool| {
let tool_type = tool.get("type").and_then(|t| t.as_str()).unwrap_or("");
if is_hosted_tool_type(tool_type) {
tool.clone()
} else {
convert_tool_to_anthropic(tool)
}
})
.collect();
body["tools"] = json!(anthropic_tools);
}
let reasoning_effort = body
.as_object_mut()
.and_then(|o| o.remove("reasoning_effort"))
.and_then(|v| v.as_str().map(String::from))
.or_else(|| {
body.pointer("/extra_body/reasoning_effort")
.and_then(|v| v.as_str().map(String::from))
});
if let Some(effort) = reasoning_effort {
let budget_tokens: u64 = match effort.as_str() {
"low" => 1024,
"medium" => 4096,
"high" => 16384,
_ => 4096, };
body["thinking"] = json!({
"type": "enabled",
"budget_tokens": budget_tokens
});
let min_max_tokens = budget_tokens + 1;
let current_max = body.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
if current_max < min_max_tokens {
body["max_tokens"] = json!(min_max_tokens);
}
}
if let Some(response_format) = body.as_object_mut().and_then(|o| o.remove("response_format")) {
let rf_type = response_format.get("type").and_then(|t| t.as_str()).unwrap_or("");
match rf_type {
"json_object" => {
let instruction = json!({"type": "text", "text": "Respond with valid JSON only. Do not include any text outside the JSON object."});
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
system.insert(0, instruction);
} else {
body["system"] = json!([instruction]);
}
}
"json_schema" => {
if let Some(schema_def) = response_format.get("json_schema") {
let schema_name = schema_def.get("name").and_then(|n| n.as_str()).unwrap_or("output");
let schema = schema_def.get("schema").cloned().unwrap_or(json!({}));
let schema_str = serde_json::to_string_pretty(&schema).unwrap_or_default();
let instruction_text = format!(
"Respond with valid JSON matching the following schema named '{schema_name}':\n```json\n{schema_str}\n```\nDo not include any text outside the JSON object."
);
let instruction = json!({"type": "text", "text": instruction_text});
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
system.insert(0, instruction);
} else {
body["system"] = json!([instruction]);
}
}
}
_ => {} }
}
if let Some(obj) = body.as_object_mut() {
for key in &[
"n",
"presence_penalty",
"frequency_penalty",
"logit_bias",
"stream_options",
"parallel_tool_calls",
"service_tier",
"user",
"reasoning_effort",
"extra_body",
] {
obj.remove(*key);
}
}
Ok(())
}
fn transform_response(&self, body: &mut Value) -> Result<()> {
if body.get("stop_reason").is_none() {
return Ok(());
}
let id = body.get("id").cloned().unwrap_or(json!(""));
let model = body.get("model").cloned().unwrap_or(json!(""));
let content_blocks = body.get("content").and_then(|v| v.as_array()).cloned();
let text_content: Option<String> = content_blocks.as_ref().map(|blocks| {
blocks
.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
});
let tool_calls: Option<Vec<Value>> = content_blocks.as_ref().map(|blocks| {
blocks
.iter()
.filter(|b| {
matches!(
b.get("type").and_then(|t| t.as_str()),
Some("tool_use") | Some("server_tool_use")
)
})
.map(|b| {
let arguments = serde_json::to_string(b.get("input").unwrap_or(&json!({}))).unwrap_or_default();
json!({
"id": b.get("id").cloned().unwrap_or(json!("")),
"type": "function",
"function": {
"name": b.get("name").cloned().unwrap_or(json!("")),
"arguments": arguments
}
})
})
.collect()
});
let stop_reason = body.get("stop_reason").and_then(|v| v.as_str()).unwrap_or("end_turn");
let finish_reason = map_stop_reason(stop_reason);
let input_tokens = body
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_creation_tokens = body
.pointer("/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_read_tokens = body
.pointer("/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let output_tokens = body
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let prompt_tokens = input_tokens + cache_creation_tokens + cache_read_tokens;
let has_tool_calls = tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
let message_content = if has_tool_calls && text_content.as_deref().unwrap_or("").is_empty() {
Value::Null
} else {
json!(text_content)
};
let mut message = json!({
"role": "assistant",
"content": message_content
});
if let (Some(tc), true) = (tool_calls, has_tool_calls) {
message["tool_calls"] = json!(tc);
}
*body = json!({
"id": id,
"object": "chat.completion",
"created": super::unix_timestamp_secs(),
"model": model,
"choices": [{
"index": 0,
"message": message,
"finish_reason": finish_reason
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": output_tokens,
"total_tokens": prompt_tokens + output_tokens
}
});
Ok(())
}
fn parse_stream_event(&self, event_data: &str) -> Result<Option<ChatCompletionChunk>> {
let event: Value = serde_json::from_str(event_data).map_err(|e| LiterLlmError::Streaming {
message: format!("failed to parse Anthropic SSE event: {e}"),
})?;
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
match event_type {
"message_start" => {
let msg = &event["message"];
let id = msg.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let model = msg.get("model").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let input_tokens = msg.pointer("/usage/input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_creation = msg
.pointer("/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_read = msg
.pointer("/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let prompt_tokens = input_tokens + cache_creation + cache_read;
let usage = if prompt_tokens > 0 {
Some(crate::types::Usage {
prompt_tokens,
completion_tokens: 0,
total_tokens: prompt_tokens,
})
} else {
None
};
Ok(Some(ChatCompletionChunk {
id,
object: "chat.completion.chunk".to_owned(),
created: super::unix_timestamp_secs(),
model,
choices: vec![StreamChoice {
index: 0,
delta: StreamDelta {
role: Some("assistant".to_owned()),
content: None,
tool_calls: None,
function_call: None,
refusal: None,
},
finish_reason: None,
}],
usage,
system_fingerprint: None,
service_tier: None,
}))
}
"content_block_start" => {
let block = &event["content_block"];
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
let anthropic_index = event.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
if block_type == "tool_use" || block_type == "server_tool_use" {
let tool_id = block.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let tool_name = block.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
return Ok(Some(make_empty_chunk_with_tool_start(
anthropic_index,
tool_id,
tool_name,
)));
}
Ok(None)
}
"content_block_delta" => {
let delta = &event["delta"];
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
let index = event.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
match delta_type {
"text_delta" => {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("");
Ok(Some(make_text_chunk("", "", text)))
}
"thinking_delta" => {
Ok(None)
}
"input_json_delta" => {
let partial_json = delta.get("partial_json").and_then(|v| v.as_str()).unwrap_or("");
Ok(Some(make_tool_arguments_delta(index, partial_json)))
}
_ => Ok(None),
}
}
"message_delta" => {
let stop_reason = event.pointer("/delta/stop_reason").and_then(|v| v.as_str());
let finish_reason = stop_reason.map(map_stop_reason);
let output_tokens = event.pointer("/usage/output_tokens").and_then(|v| v.as_u64());
let finish = finish_reason.map(|fr| match fr {
"stop" => FinishReason::Stop,
"length" => FinishReason::Length,
"tool_calls" => FinishReason::ToolCalls,
_ => FinishReason::Other,
});
let usage = output_tokens.map(|ct| crate::types::Usage {
prompt_tokens: 0,
completion_tokens: ct,
total_tokens: ct,
});
Ok(Some(ChatCompletionChunk {
id: String::new(),
object: "chat.completion.chunk".to_owned(),
created: super::unix_timestamp_secs(),
model: String::new(),
choices: vec![StreamChoice {
index: 0,
delta: StreamDelta {
role: None,
content: None,
tool_calls: None,
function_call: None,
refusal: None,
},
finish_reason: finish,
}],
usage,
system_fingerprint: None,
service_tier: None,
}))
}
"message_stop" => Ok(None),
"content_block_stop" | "ping" => {
Ok(None)
}
"error" => {
let message = event
.pointer("/error/message")
.and_then(|v| v.as_str())
.unwrap_or("unknown Anthropic streaming error");
Err(LiterLlmError::Streaming {
message: message.to_owned(),
})
}
_ => {
Ok(None)
}
}
}
}
fn convert_image_url_to_anthropic_source(url: &str) -> Value {
if url.starts_with("data:")
&& let Some((header, data)) = url.split_once(',')
{
let media_type = header.trim_start_matches("data:").trim_end_matches(";base64");
return json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
});
}
json!({
"type": "image",
"source": {"type": "url", "url": url}
})
}
fn sanitize_tool_call_id(id: &str) -> Cow<'_, str> {
if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
Cow::Borrowed(id)
} else {
Cow::Owned(
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect(),
)
}
}
fn merge_consecutive_same_role(messages: Vec<Value>) -> Vec<Value> {
let mut merged: Vec<Value> = Vec::new();
for msg in messages {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
if let Some(last) = merged.last_mut() {
let last_role = last.get("role").and_then(|r| r.as_str()).unwrap_or("");
if last_role == role {
let incoming_content = match msg.get("content") {
Some(Value::Array(arr)) => arr.clone(),
Some(Value::String(s)) => vec![json!({"type": "text", "text": s})],
Some(other) => vec![json!({"type": "text", "text": other.to_string()})],
None => vec![],
};
if let Some(Value::Array(existing)) = last.get_mut("content") {
existing.extend(incoming_content);
} else {
let existing_content = match last.get("content") {
Some(Value::String(s)) => vec![json!({"type": "text", "text": s.clone()})],
Some(Value::Array(arr)) => arr.clone(),
Some(other) => vec![json!({"type": "text", "text": other.to_string()})],
None => vec![],
};
let mut combined = existing_content;
combined.extend(incoming_content);
last["content"] = json!(combined);
}
continue;
}
}
merged.push(msg);
}
merged
}
fn convert_message_to_anthropic(msg: Value) -> Value {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
match role {
"user" => {
let content = convert_user_content_to_anthropic(msg.get("content"));
let mut user_msg = json!({"role": "user", "content": content});
if let Some(cc) = msg.get("cache_control")
&& let Some(blocks) = user_msg.get_mut("content").and_then(|c| c.as_array_mut())
&& let Some(last) = blocks.last_mut()
{
last["cache_control"] = cc.clone();
}
user_msg
}
"assistant" => {
let mut blocks: Vec<Value> = Vec::new();
if let Some(text) = msg.get("content").and_then(|c| c.as_str())
&& !text.is_empty()
{
let mut block = json!({"type": "text", "text": text});
if let Some(cc) = msg.get("cache_control") {
block["cache_control"] = cc.clone();
}
blocks.push(block);
}
if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = tc.pointer("/function/name").and_then(|v| v.as_str()).unwrap_or("");
let arguments_str = tc
.pointer("/function/arguments")
.and_then(|v| v.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(arguments_str).unwrap_or_else(|_| json!({}));
blocks.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
let has_tool_use = blocks
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"));
if blocks.is_empty() {
blocks.push(json!({"type": "text", "text": ""}));
} else if !has_tool_use {
}
json!({"role": "assistant", "content": blocks})
}
"tool" => {
let raw_id = msg.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or("");
let tool_use_id = sanitize_tool_call_id(raw_id);
let result_content = match msg.get("content") {
Some(Value::Array(arr)) => {
arr.iter()
.map(|part| {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("text");
match part_type {
"image_url" => {
let url = part.pointer("/image_url/url").and_then(|u| u.as_str()).unwrap_or("");
convert_image_url_to_anthropic_source(url)
}
_ => {
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
json!({"type": "text", "text": text})
}
}
})
.collect::<Vec<_>>()
}
Some(Value::String(s)) => vec![json!({"type": "text", "text": s})],
_ => vec![json!({"type": "text", "text": ""})],
};
let mut tool_result_block = json!({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result_content
});
if let Some(cc) = msg.get("cache_control") {
tool_result_block["cache_control"] = cc.clone();
}
json!({
"role": "user",
"content": [tool_result_block]
})
}
"function" => {
let name = msg.get("name").and_then(|v| v.as_str()).unwrap_or("");
let sanitized_name = sanitize_tool_call_id(name);
let content_text = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": sanitized_name,
"content": [{"type": "text", "text": content_text}]
}]
})
}
_ => {
msg
}
}
}
fn convert_user_content_to_anthropic(content: Option<&Value>) -> Value {
match content {
None => json!([]),
Some(Value::String(s)) => json!([{"type": "text", "text": s}]),
Some(Value::Array(parts)) => {
let blocks: Vec<Value> = parts
.iter()
.filter_map(|part| {
let part_type = part.get("type").and_then(|t| t.as_str())?;
match part_type {
"text" => {
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
let mut block = json!({"type": "text", "text": text});
if let Some(cc) = part.get("cache_control") {
block["cache_control"] = cc.clone();
}
Some(block)
}
"image_url" => {
let url = part.pointer("/image_url/url").and_then(|u| u.as_str())?;
let mut block = convert_image_url_to_anthropic_source(url);
if let Some(cc) = part.get("cache_control") {
block["cache_control"] = cc.clone();
}
Some(block)
}
"document" => {
let data = part.pointer("/document/data").and_then(|d| d.as_str())?;
let media_type = part
.pointer("/document/media_type")
.and_then(|m| m.as_str())
.unwrap_or("application/pdf");
let mut block = json!({
"type": "document",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
});
if let Some(cc) = part.get("cache_control") {
block["cache_control"] = cc.clone();
}
Some(block)
}
_ => {
#[cfg(feature = "tracing")]
tracing::warn!(
part_type = part_type,
"unrecognized user content part type; falling back to text"
);
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
if text.is_empty() {
None
} else {
Some(json!({"type": "text", "text": text}))
}
}
}
})
.collect();
json!(blocks)
}
Some(other) => json!([{"type": "text", "text": other.to_string()}]),
}
}
fn convert_tool_choice(tool_choice: &Value) -> Option<Value> {
match tool_choice {
Value::String(s) => match s.as_str() {
"none" => None,
"required" => Some(json!({"type": "any"})),
_ => Some(json!({"type": "auto"})),
},
Value::Object(_) => {
let name = tool_choice.pointer("/function/name").and_then(|v| v.as_str());
if let Some(name) = name {
Some(json!({"type": "tool", "name": name}))
} else {
Some(json!({"type": "auto"}))
}
}
_ => Some(json!({"type": "auto"})),
}
}
fn convert_tool_to_anthropic(tool: &Value) -> Value {
let function = tool.get("function");
let name = function.and_then(|f| f.get("name")).cloned().unwrap_or(json!(""));
let description = function.and_then(|f| f.get("description")).cloned();
let mut parameters = function
.and_then(|f| f.get("parameters"))
.cloned()
.unwrap_or(json!({"type": "object", "properties": {}}));
if parameters.get("type").and_then(|t| t.as_str()) != Some("object") {
parameters["type"] = json!("object");
}
let mut tool_def = json!({
"name": name,
"input_schema": parameters
});
if let Some(desc) = description {
tool_def["description"] = desc;
}
if let Some(cc) = tool.get("cache_control") {
tool_def["cache_control"] = cc.clone();
} else if let Some(cc) = function.and_then(|f| f.get("cache_control")) {
tool_def["cache_control"] = cc.clone();
}
tool_def
}
fn is_hosted_tool_type(tool_type: &str) -> bool {
HOSTED_TOOL_TYPES.contains(&tool_type)
}
fn body_contains_cache_control(body: &Value) -> bool {
match body {
Value::Object(map) => {
if map.contains_key("cache_control") {
return true;
}
map.values().any(body_contains_cache_control)
}
Value::Array(arr) => arr.iter().any(body_contains_cache_control),
_ => false,
}
}
fn body_contains_document_block(body: &Value) -> bool {
if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
for msg in messages {
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
for part in content {
if part.get("type").and_then(|t| t.as_str()) == Some("document") {
return true;
}
}
}
}
}
false
}
fn map_stop_reason(stop_reason: &str) -> &'static str {
match stop_reason {
"end_turn" | "stop_sequence" => "stop",
"tool_use" => "tool_calls",
"max_tokens" => "length",
"content_filtered" | "refusal" => "content_filter",
_ => "stop",
}
}
fn make_text_chunk(id: &str, model: &str, text: &str) -> ChatCompletionChunk {
ChatCompletionChunk {
id: id.to_owned(),
object: "chat.completion.chunk".to_owned(),
created: super::unix_timestamp_secs(),
model: model.to_owned(),
choices: vec![StreamChoice {
index: 0,
delta: StreamDelta {
role: None,
content: Some(text.to_owned()),
tool_calls: None,
function_call: None,
refusal: None,
},
finish_reason: None,
}],
usage: None,
system_fingerprint: None,
service_tier: None,
}
}
fn make_empty_chunk_with_tool_start(tool_index: u32, tool_id: String, tool_name: String) -> ChatCompletionChunk {
ChatCompletionChunk {
id: String::new(),
object: "chat.completion.chunk".to_owned(),
created: super::unix_timestamp_secs(),
model: String::new(),
choices: vec![StreamChoice {
index: 0,
delta: StreamDelta {
role: None,
content: None,
tool_calls: Some(vec![StreamToolCall {
index: tool_index,
id: Some(tool_id),
call_type: Some(crate::types::ToolType::Function),
function: Some(StreamFunctionCall {
name: Some(tool_name),
arguments: None,
}),
}]),
function_call: None,
refusal: None,
},
finish_reason: None,
}],
usage: None,
system_fingerprint: None,
service_tier: None,
}
}
fn make_tool_arguments_delta(tool_index: u32, partial_json: &str) -> ChatCompletionChunk {
ChatCompletionChunk {
id: String::new(),
object: "chat.completion.chunk".to_owned(),
created: super::unix_timestamp_secs(),
model: String::new(),
choices: vec![StreamChoice {
index: 0,
delta: StreamDelta {
role: None,
content: None,
tool_calls: Some(vec![StreamToolCall {
index: tool_index,
id: None,
call_type: None,
function: Some(StreamFunctionCall {
name: None,
arguments: Some(partial_json.to_owned()),
}),
}]),
function_call: None,
refusal: None,
},
finish_reason: None,
}],
usage: None,
system_fingerprint: None,
service_tier: None,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn provider() -> AnthropicProvider {
AnthropicProvider
}
#[test]
fn transform_request_extracts_system_message() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"}
]
});
provider().transform_request(&mut body).unwrap();
assert_eq!(
body["system"],
json!([{"type": "text", "text": "You are a helpful assistant."}])
);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
}
#[test]
fn transform_request_multiple_system_messages_merged() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "system", "content": "First instruction."},
{"role": "system", "content": "Second instruction."},
{"role": "user", "content": "Question"}
]
});
provider().transform_request(&mut body).unwrap();
let system = body["system"].as_array().unwrap();
assert_eq!(system.len(), 2);
assert_eq!(system[0]["text"], "First instruction.");
assert_eq!(system[1]["text"], "Second instruction.");
}
#[test]
fn transform_request_defaults_max_tokens() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}]
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["max_tokens"], json!(DEFAULT_MAX_TOKENS));
}
#[test]
fn transform_request_preserves_explicit_max_tokens() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 1024
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["max_tokens"], json!(1024u64));
}
#[test]
fn transform_request_converts_stop_string_to_array() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"stop": "\n"
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["stop_sequences"], json!(["\n"]));
assert!(body.get("stop").is_none(), "old `stop` key should be removed");
}
#[test]
fn transform_request_stop_array_passes_through() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"stop": ["STOP", "END"]
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["stop_sequences"], json!(["STOP", "END"]));
assert!(body.get("stop").is_none());
}
#[test]
fn transform_request_tool_choice_required_maps_to_any() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"tool_choice": "required",
"tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}]
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["tool_choice"], json!({"type": "any"}));
}
#[test]
fn transform_request_tool_choice_none_removes_tools() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"tool_choice": "none",
"tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}]
});
provider().transform_request(&mut body).unwrap();
assert!(body.get("tool_choice").is_none(), "tool_choice should be removed");
assert!(
body.get("tools").is_none(),
"tools should be removed for tool_choice=none"
);
}
#[test]
fn transform_request_tool_choice_specific_function() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"tool_choice": {"type": "function", "function": {"name": "my_tool"}},
"tools": [{"type": "function", "function": {"name": "my_tool", "parameters": {}}}]
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["tool_choice"], json!({"type": "tool", "name": "my_tool"}));
}
#[test]
fn transform_request_converts_tools_to_anthropic_format() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather",
"parameters": {"type": "object", "properties": {}}
}
}]
});
provider().transform_request(&mut body).unwrap();
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["name"], "get_weather");
assert_eq!(tools[0]["description"], "Get current weather");
assert!(tools[0].get("input_schema").is_some());
assert!(tools[0].get("function").is_none());
}
#[test]
fn transform_request_removes_unsupported_fields() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"n": 2,
"presence_penalty": 0.5,
"frequency_penalty": 0.3,
"logit_bias": {"1234": 5},
"stream": true
});
provider().transform_request(&mut body).unwrap();
for key in &["n", "presence_penalty", "frequency_penalty", "logit_bias"] {
assert!(body.get(key).is_none(), "`{key}` should be removed");
}
assert_eq!(body["stream"], true);
}
#[test]
fn transform_request_converts_tool_message_to_tool_result() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "What is the weather?"},
{"role": "assistant", "content": null, "tool_calls": [{
"id": "call_abc",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"London\"}"}
}]},
{"role": "tool", "tool_call_id": "call_abc", "content": "15°C, sunny"}
]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let tool_result_msg = &messages[2];
assert_eq!(tool_result_msg["role"], "user");
let content = tool_result_msg["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_abc");
}
#[test]
fn transform_request_converts_user_content_parts() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/abc=="}}
]
}]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/jpeg");
}
#[test]
fn transform_response_basic_text() {
let mut body = json!({
"id": "msg_01Xfn7",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "Hello, world!"}],
"model": "claude-3-5-sonnet-20241022",
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
});
provider().transform_response(&mut body).unwrap();
assert_eq!(body["object"], "chat.completion");
assert_eq!(body["id"], "msg_01Xfn7");
let choice = &body["choices"][0];
assert_eq!(choice["message"]["content"], "Hello, world!");
assert_eq!(choice["finish_reason"], "stop");
assert_eq!(body["usage"]["prompt_tokens"], 10);
assert_eq!(body["usage"]["completion_tokens"], 5);
assert_eq!(body["usage"]["total_tokens"], 15);
}
#[test]
fn transform_response_stop_reason_max_tokens_maps_to_length() {
let mut body = json!({
"id": "msg_abc",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "truncated"}],
"model": "claude-3-haiku-20240307",
"stop_reason": "max_tokens",
"usage": {"input_tokens": 5, "output_tokens": 50}
});
provider().transform_response(&mut body).unwrap();
assert_eq!(body["choices"][0]["finish_reason"], "length");
}
#[test]
fn transform_response_tool_use_block() {
let mut body = json!({
"id": "msg_tool",
"type": "message",
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "toolu_01abc",
"name": "get_weather",
"input": {"location": "London"}
}],
"model": "claude-3-5-sonnet-20241022",
"stop_reason": "tool_use",
"usage": {"input_tokens": 20, "output_tokens": 10}
});
provider().transform_response(&mut body).unwrap();
let choice = &body["choices"][0];
assert_eq!(choice["finish_reason"], "tool_calls");
assert_eq!(choice["message"]["content"], Value::Null);
let tool_calls = choice["message"]["tool_calls"].as_array().unwrap();
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["id"], "toolu_01abc");
assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
let args_str = tool_calls[0]["function"]["arguments"].as_str().unwrap();
let args: Value = serde_json::from_str(args_str).unwrap();
assert_eq!(args["location"], "London");
}
#[test]
fn transform_response_is_noop_for_openai_format() {
let original = json!({
"id": "chatcmpl-xxx",
"object": "chat.completion",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}]
});
let mut body = original.clone();
provider().transform_response(&mut body).unwrap();
assert_eq!(body, original);
}
#[test]
fn parse_stream_event_done_is_handled_at_sse_level() {
let result = provider().parse_stream_event("[DONE]");
assert!(
result.is_err(),
"[DONE] is not valid JSON and should error if it reaches the provider"
);
}
#[test]
fn parse_stream_event_message_stop_returns_none() {
let event = r#"{"type":"message_stop"}"#;
let result = provider().parse_stream_event(event).unwrap();
assert!(result.is_none());
}
#[test]
fn parse_stream_event_text_delta() {
let event = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("Hello"));
}
#[test]
fn parse_stream_event_message_delta_with_finish_reason() {
let event = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#;
let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
assert_eq!(chunk.choices[0].finish_reason, Some(FinishReason::Stop));
let usage = chunk.usage.unwrap();
assert_eq!(usage.completion_tokens, 12);
}
#[test]
fn parse_stream_event_message_delta_tool_use_stop_reason() {
let event = r#"{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}"#;
let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
assert_eq!(chunk.choices[0].finish_reason, Some(FinishReason::ToolCalls));
}
#[test]
fn parse_stream_event_message_start() {
let event = r#"{"type":"message_start","message":{"id":"msg_abc","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-20241022","stop_reason":null,"usage":{"input_tokens":25,"output_tokens":1}}}"#;
let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
assert_eq!(chunk.id, "msg_abc");
assert_eq!(chunk.model, "claude-3-5-sonnet-20241022");
assert_eq!(chunk.choices[0].delta.role.as_deref(), Some("assistant"));
let usage = chunk.usage.unwrap();
assert_eq!(usage.prompt_tokens, 25);
}
#[test]
fn parse_stream_event_input_json_delta() {
let event =
r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"loc"}}"#;
let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
let tc = &chunk.choices[0].delta.tool_calls.as_ref().unwrap()[0];
assert_eq!(tc.function.as_ref().unwrap().arguments.as_deref(), Some("{\"loc"));
}
#[test]
fn parse_stream_event_error_returns_err() {
let event = r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
let result = provider().parse_stream_event(event);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Overloaded"));
}
#[test]
fn parse_stream_event_ping_returns_none() {
let event = r#"{"type":"ping"}"#;
let result = provider().parse_stream_event(event).unwrap();
assert!(result.is_none(), "ping should return Ok(None), not a chunk");
}
#[test]
fn parse_stream_event_content_block_stop_returns_none() {
let event = r#"{"type":"content_block_stop","index":0}"#;
let result = provider().parse_stream_event(event).unwrap();
assert!(result.is_none(), "content_block_stop should return Ok(None)");
}
#[test]
fn chat_completions_path_is_messages() {
assert_eq!(provider().chat_completions_path(), "/messages");
}
#[test]
fn transform_request_empty_messages_returns_error() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": []
});
let result = provider().transform_request(&mut body);
assert!(result.is_err(), "empty messages should return an error");
}
#[test]
fn transform_request_sanitizes_tool_call_id() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "What is the weather?"},
{"role": "assistant", "content": null, "tool_calls": [{
"id": "call_abc.123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"}
}]},
{"role": "tool", "tool_call_id": "call_abc.123", "content": "Sunny"}
]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let tool_result_msg = messages
.iter()
.find(|m| m["role"] == "user" && m["content"][0]["type"] == "tool_result")
.unwrap();
assert_eq!(tool_result_msg["content"][0]["tool_use_id"], "call_abc_123");
}
#[test]
fn transform_request_merges_consecutive_user_messages() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "First"},
{"role": "user", "content": "Second"}
]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
}
#[test]
fn transform_request_system_content_array_passed_through() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "system", "content": [
{"type": "text", "text": "Block one"},
{"type": "text", "text": "Block two"}
]},
{"role": "user", "content": "Hello"}
]
});
provider().transform_request(&mut body).unwrap();
let system = body["system"].as_array().unwrap();
assert_eq!(system.len(), 2);
assert_eq!(system[0]["text"], "Block one");
}
#[test]
fn transform_request_system_cache_control_propagated() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "system", "content": "Cached instructions", "cache_control": {"type": "ephemeral"}},
{"role": "user", "content": "Hi"}
]
});
provider().transform_request(&mut body).unwrap();
let system = body["system"].as_array().unwrap();
assert_eq!(system[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn transform_request_user_content_cache_control_propagated() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Cached text", "cache_control": {"type": "ephemeral"}}
]
}]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn transform_request_tool_input_schema_type_normalized() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"tools": [{
"type": "function",
"function": {
"name": "my_tool",
"parameters": {"properties": {}}
}
}]
});
provider().transform_request(&mut body).unwrap();
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools[0]["input_schema"]["type"], "object");
}
#[test]
fn transform_request_max_completion_tokens_mapped() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"max_completion_tokens": 512
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["max_tokens"], json!(512u64));
assert!(body.get("max_completion_tokens").is_none());
}
#[test]
fn transform_request_tool_result_content_array_preserved() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "Look"},
{"role": "assistant", "content": null, "tool_calls": [{
"id": "call_img",
"type": "function",
"function": {"name": "get_image", "arguments": "{}"}
}]},
{"role": "tool", "tool_call_id": "call_img", "content": [
{"type": "text", "text": "Here is the image"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}
]}
]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let tool_result_msg = messages
.iter()
.find(|m| {
m["role"] == "user"
&& m["content"]
.as_array()
.is_some_and(|c| c.first().is_some_and(|b| b["type"] == "tool_result"))
})
.unwrap();
let result_content = tool_result_msg["content"][0]["content"].as_array().unwrap();
assert_eq!(result_content.len(), 2);
assert_eq!(result_content[0]["type"], "text");
assert_eq!(result_content[1]["type"], "image");
}
#[test]
fn transform_response_thinking_block_excluded_from_content() {
let mut body = json!({
"id": "msg_think",
"type": "message",
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me reason..."},
{"type": "text", "text": "The answer is 42."}
],
"model": "claude-3-5-sonnet-20241022",
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 20}
});
provider().transform_response(&mut body).unwrap();
let content = body["choices"][0]["message"]["content"].as_str().unwrap();
assert!(
!content.contains("Let me reason..."),
"thinking blocks should be filtered out"
);
assert_eq!(content, "The answer is 42.");
}
#[test]
fn transform_response_server_tool_use_treated_as_tool_call() {
let mut body = json!({
"id": "msg_srv",
"type": "message",
"role": "assistant",
"content": [{
"type": "server_tool_use",
"id": "srvtool_01",
"name": "web_search",
"input": {"query": "Rust programming"}
}],
"model": "claude-3-5-sonnet-20241022",
"stop_reason": "tool_use",
"usage": {"input_tokens": 5, "output_tokens": 5}
});
provider().transform_response(&mut body).unwrap();
let tool_calls = body["choices"][0]["message"]["tool_calls"].as_array().unwrap();
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["id"], "srvtool_01");
assert_eq!(tool_calls[0]["function"]["name"], "web_search");
}
#[test]
fn transform_response_cache_tokens_counted_in_prompt() {
let mut body = json!({
"id": "msg_cache",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "ok"}],
"model": "claude-3-5-sonnet-20241022",
"stop_reason": "end_turn",
"usage": {
"input_tokens": 100,
"cache_creation_input_tokens": 50,
"cache_read_input_tokens": 25,
"output_tokens": 10
}
});
provider().transform_response(&mut body).unwrap();
assert_eq!(body["usage"]["prompt_tokens"], 175u64);
assert_eq!(body["usage"]["completion_tokens"], 10u64);
assert_eq!(body["usage"]["total_tokens"], 185u64);
}
#[test]
fn transform_response_tool_only_no_empty_text_block_in_request() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "Call a tool"},
{"role": "assistant", "tool_calls": [{
"id": "call_xyz",
"type": "function",
"function": {"name": "my_fn", "arguments": "{}"}
}]},
{"role": "tool", "tool_call_id": "call_xyz", "content": "result"}
]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let assistant_msg = messages.iter().find(|m| m["role"] == "assistant").unwrap();
let blocks = assistant_msg["content"].as_array().unwrap();
assert!(blocks.iter().all(|b| b["type"] != "text" || b["text"] != ""));
assert!(blocks.iter().any(|b| b["type"] == "tool_use"));
}
#[test]
fn parse_stream_event_thinking_delta_returns_none() {
let event = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"I am thinking..."}}"#;
let result = provider().parse_stream_event(event).unwrap();
assert!(result.is_none(), "thinking_delta should be filtered (return None)");
}
#[test]
fn parse_stream_event_message_start_cache_tokens_in_usage() {
let event = r#"{"type":"message_start","message":{"id":"msg_x","model":"claude-opus","content":[],"usage":{"input_tokens":100,"cache_creation_input_tokens":50,"cache_read_input_tokens":25,"output_tokens":0}}}"#;
let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
let usage = chunk.usage.unwrap();
assert_eq!(usage.prompt_tokens, 175);
}
#[test]
fn sanitize_tool_call_id_replaces_invalid_chars() {
assert_eq!(sanitize_tool_call_id("call.abc!123").as_ref(), "call_abc_123");
assert_eq!(sanitize_tool_call_id("call-abc_123").as_ref(), "call-abc_123");
assert_eq!(sanitize_tool_call_id("call abc").as_ref(), "call_abc");
assert!(matches!(sanitize_tool_call_id("toolu_01abc"), Cow::Borrowed(_)));
assert!(matches!(sanitize_tool_call_id("call.123"), Cow::Owned(_)));
}
#[test]
fn map_stop_reason_content_filter() {
assert_eq!(map_stop_reason("content_filtered"), "content_filter");
assert_eq!(map_stop_reason("refusal"), "content_filter");
}
#[test]
fn transform_request_reasoning_effort_low() {
let mut body = json!({
"model": "claude-sonnet-4-20250514",
"messages": [{"role": "user", "content": "Think about this"}],
"reasoning_effort": "low"
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 1024);
assert!(
body.get("reasoning_effort").is_none(),
"reasoning_effort should be removed"
);
}
#[test]
fn transform_request_reasoning_effort_medium() {
let mut body = json!({
"model": "claude-sonnet-4-20250514",
"messages": [{"role": "user", "content": "Think about this"}],
"reasoning_effort": "medium"
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 4096);
}
#[test]
fn transform_request_reasoning_effort_high() {
let mut body = json!({
"model": "claude-sonnet-4-20250514",
"messages": [{"role": "user", "content": "Think deeply"}],
"reasoning_effort": "high"
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 16384);
}
#[test]
fn transform_request_reasoning_effort_from_extra_body() {
let mut body = json!({
"model": "claude-sonnet-4-20250514",
"messages": [{"role": "user", "content": "Think"}],
"extra_body": {"reasoning_effort": "high"}
});
provider().transform_request(&mut body).unwrap();
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 16384);
}
#[test]
fn dynamic_headers_thinking_beta() {
let body = json!({
"thinking": {"type": "enabled", "budget_tokens": 4096},
"messages": [{"role": "user", "content": "Hi"}]
});
let headers = provider().dynamic_headers(&body);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "anthropic-beta");
assert!(headers[0].1.contains("thinking-2025-04-14"));
}
#[test]
fn dynamic_headers_web_search_beta() {
let body = json!({
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
"messages": [{"role": "user", "content": "Search for Rust"}]
});
let headers = provider().dynamic_headers(&body);
assert_eq!(headers.len(), 1);
assert!(headers[0].1.contains("web-search-2025-03-05"));
}
#[test]
fn dynamic_headers_multiple_betas_combined() {
let body = json!({
"thinking": {"type": "enabled", "budget_tokens": 4096},
"tools": [
{"type": "computer_use_20250124", "display_width_px": 1024, "display_height_px": 768},
{"type": "web_search_20250305", "name": "web_search"}
]
});
let headers = provider().dynamic_headers(&body);
assert_eq!(headers.len(), 1);
let beta_value = &headers[0].1;
assert!(beta_value.contains("thinking-2025-04-14"));
assert!(beta_value.contains("computer-use-2025-01-24"));
assert!(beta_value.contains("web-search-2025-03-05"));
}
#[test]
fn dynamic_headers_no_betas_returns_empty() {
let body = json!({
"messages": [{"role": "user", "content": "Hi"}]
});
let headers = provider().dynamic_headers(&body);
assert!(headers.is_empty());
}
#[test]
fn dynamic_headers_code_execution_beta() {
let body = json!({
"tools": [{"type": "code_execution_20250522"}]
});
let headers = provider().dynamic_headers(&body);
assert_eq!(headers.len(), 1);
assert!(headers[0].1.contains("code-execution-2025-05-22"));
}
#[test]
fn transform_request_tool_cache_control_propagated() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hi"}],
"tools": [{
"type": "function",
"cache_control": {"type": "ephemeral"},
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}}
}
}]
});
provider().transform_request(&mut body).unwrap();
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn transform_request_assistant_message_cache_control() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!", "cache_control": {"type": "ephemeral"}},
{"role": "user", "content": "How are you?"}
]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let assistant_msg = messages.iter().find(|m| m["role"] == "assistant").unwrap();
let content = assistant_msg["content"].as_array().unwrap();
assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn transform_request_user_message_level_cache_control() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{
"role": "user",
"content": "Hello",
"cache_control": {"type": "ephemeral"}
}]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn transform_request_document_content_part() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this document"},
{"type": "document", "document": {
"data": "JVBERi0xLjQ=",
"media_type": "application/pdf"
}}
]
}]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "document");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "application/pdf");
assert_eq!(content[1]["source"]["data"], "JVBERi0xLjQ=");
}
#[test]
fn transform_request_document_with_cache_control() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{
"role": "user",
"content": [
{"type": "document", "document": {
"data": "JVBERi0xLjQ=",
"media_type": "application/pdf"
}, "cache_control": {"type": "ephemeral"}}
]
}]
});
provider().transform_request(&mut body).unwrap();
let messages = body["messages"].as_array().unwrap();
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn transform_request_json_object_response_format() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Give me JSON"}],
"response_format": {"type": "json_object"}
});
provider().transform_request(&mut body).unwrap();
assert!(body.get("response_format").is_none());
let system = body["system"].as_array().unwrap();
assert!(system[0]["text"].as_str().unwrap().contains("valid JSON"));
}
#[test]
fn transform_request_json_schema_response_format() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Give me structured output"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "person",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
}
}
}
}
});
provider().transform_request(&mut body).unwrap();
assert!(body.get("response_format").is_none());
let system = body["system"].as_array().unwrap();
let instruction = system[0]["text"].as_str().unwrap();
assert!(instruction.contains("person"));
assert!(instruction.contains("schema"));
}
#[test]
fn transform_request_json_object_with_existing_system() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Give me JSON"}
],
"response_format": {"type": "json_object"}
});
provider().transform_request(&mut body).unwrap();
let system = body["system"].as_array().unwrap();
assert_eq!(system.len(), 2);
assert!(system[0]["text"].as_str().unwrap().contains("valid JSON"));
assert_eq!(system[1]["text"], "You are helpful.");
}
#[test]
fn transform_request_hosted_tool_passed_through() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Search the web"}],
"tools": [
{"type": "web_search_20250305", "name": "web_search", "max_uses": 3},
{"type": "function", "function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {}}
}}
]
});
provider().transform_request(&mut body).unwrap();
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0]["type"], "web_search_20250305");
assert_eq!(tools[0]["max_uses"], 3);
assert_eq!(tools[1]["name"], "get_weather");
assert!(tools[1].get("input_schema").is_some());
}
#[test]
fn transform_request_computer_use_tool_passed_through() {
let mut body = json!({
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Use the computer"}],
"tools": [{
"type": "computer_20241022",
"display_width_px": 1024,
"display_height_px": 768
}]
});
provider().transform_request(&mut body).unwrap();
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools[0]["type"], "computer_20241022");
assert_eq!(tools[0]["display_width_px"], 1024);
}
#[test]
fn transform_response_citation_blocks_skipped() {
let mut body = json!({
"id": "msg_cite",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "According to the document, "},
{"type": "citation", "cited_text": "Rust is fast", "document_index": 0},
{"type": "text", "text": "Rust is a fast language."}
],
"model": "claude-3-5-sonnet-20241022",
"stop_reason": "end_turn",
"usage": {"input_tokens": 50, "output_tokens": 20}
});
provider().transform_response(&mut body).unwrap();
let content = body["choices"][0]["message"]["content"].as_str().unwrap();
assert_eq!(content, "According to the document, Rust is a fast language.");
assert!(!content.contains("citation"));
}
#[test]
fn is_hosted_tool_type_recognizes_all_types() {
assert!(is_hosted_tool_type("computer_20241022"));
assert!(is_hosted_tool_type("computer_use_20250124"));
assert!(is_hosted_tool_type("web_search_20250305"));
assert!(is_hosted_tool_type("code_execution_20250522"));
assert!(!is_hosted_tool_type("function"));
assert!(!is_hosted_tool_type("custom_tool"));
}
}