use super::types::{
AnthropicAuth, AnthropicCacheControl, AnthropicConfig, AnthropicContent, AnthropicMessage,
AnthropicMessageContent, AnthropicRequest, AnthropicResponse, AnthropicSource,
AnthropicSystemBlock, AnthropicSystemContent, AnthropicThinkingConfig as AnthropicThinking,
CLAUDE_CODE_SYSTEM_PREFIX, infer_max_tokens,
};
use crate::error::{Error, Result};
use crate::types::{
CacheContext, CacheControlValidator, CacheWarning, CacheWarningType, ContentPart, FinishReason,
FinishReasonKind, GenerateRequest, GenerateResponse, InputTokenDetails, Message,
OutputTokenDetails, ResponseContent, Role, Usage,
};
use serde_json::json;
use std::collections::HashSet;
fn is_opus_4_7_or_later(model_id: &str) -> bool {
let id = model_id.to_lowercase();
id.starts_with("claude-opus-4-7")
}
pub struct AnthropicConversionResult {
pub request: AnthropicRequest,
pub warnings: Vec<CacheWarning>,
pub has_cache_control: bool,
}
pub fn to_anthropic_request(
req: &GenerateRequest,
config: &AnthropicConfig,
stream: bool,
) -> Result<AnthropicConversionResult> {
let mut validator = CacheControlValidator::new();
let cache_strategy = req
.options
.cache_strategy
.clone()
.unwrap_or_else(|| config.default_cache_strategy.clone());
let cache_config = cache_strategy.to_anthropic_config();
let has_tools = req.options.tools.as_ref().is_some_and(|t| !t.is_empty());
let tools = build_tools_with_caching(
&req.options.tools,
&mut validator,
cache_config
.as_ref()
.is_some_and(|c| c.cache_tools && has_tools),
)?;
let system = build_system_content_with_caching(
&req.messages,
&config.auth,
&mut validator,
cache_config.as_ref().is_some_and(|c| c.cache_system),
)?;
let tail_budget = cache_config.as_ref().map_or(0, |c| {
let used = validator.breakpoint_count();
let max = 4usize; let remaining = max.saturating_sub(used);
c.tail_message_count.min(remaining)
});
let messages = build_messages_with_caching(&req.messages, &mut validator, tail_budget)?;
let max_tokens = req
.options
.max_tokens
.unwrap_or_else(|| infer_max_tokens(&req.model.id));
let tool_choice = req.options.tool_choice.as_ref().map(|choice| match choice {
crate::types::ToolChoice::Auto => json!({"type": "auto"}),
crate::types::ToolChoice::None => json!({"type": "none"}),
crate::types::ToolChoice::Required { name } => json!({
"type": "tool",
"name": name
}),
});
let is_opus_47 = is_opus_4_7_or_later(&req.model.id);
let thinking = req.provider_options.as_ref().and_then(|opts| {
if let crate::types::ProviderOptions::Anthropic(anthropic) = opts {
anthropic.thinking.as_ref().map(|t| {
if is_opus_47 {
AnthropicThinking {
type_: "adaptive".to_string(),
budget_tokens: None,
}
} else {
AnthropicThinking {
type_: "enabled".to_string(),
budget_tokens: Some(t.budget_tokens.max(1024)),
}
}
})
} else {
None
}
});
let has_cache_control = validator.breakpoint_count() > 0;
let mut warnings = validator.take_warnings();
let (temperature, top_p) = if is_opus_47 {
if req.options.temperature.is_some() {
warnings.push(opus_47_strip_warning("temperature"));
}
if req.options.top_p.is_some() {
warnings.push(opus_47_strip_warning("top_p"));
}
(None, None)
} else {
(req.options.temperature, req.options.top_p)
};
if is_opus_47 && thinking.is_some() {
warnings.push(opus_47_thinking_rewrite_warning());
}
Ok(AnthropicConversionResult {
request: AnthropicRequest {
model: req.model.id.clone(),
messages,
max_tokens,
system,
temperature,
top_p,
top_k: None,
metadata: None,
stop_sequences: req.options.stop_sequences.clone(),
stream: if stream { Some(true) } else { None },
thinking,
tools,
tool_choice,
},
warnings,
has_cache_control,
})
}
fn opus_47_strip_warning(param: &str) -> CacheWarning {
CacheWarning::new(
CacheWarningType::UnsupportedContext,
format!(
"Claude Opus 4.7 removed the `{}` sampling parameter; it was dropped from the outgoing request.",
param
),
)
}
fn opus_47_thinking_rewrite_warning() -> CacheWarning {
CacheWarning::new(
CacheWarningType::UnsupportedContext,
"Claude Opus 4.7 removed `thinking.budget_tokens`; request rewritten to `thinking: {type: \"adaptive\"}`."
.to_string(),
)
}
fn build_system_content_with_caching(
messages: &[Message],
auth: &AnthropicAuth,
validator: &mut CacheControlValidator,
auto_cache_last: bool,
) -> Result<Option<AnthropicSystemContent>> {
let system_messages: Vec<&Message> =
messages.iter().filter(|m| m.role == Role::System).collect();
let is_oauth = matches!(auth, AnthropicAuth::OAuth { .. });
if system_messages.is_empty() && !is_oauth {
return Ok(None);
}
let has_explicit_cache = system_messages.iter().any(|m| m.cache_control().is_some());
let use_blocks = is_oauth || has_explicit_cache || auto_cache_last;
if is_oauth {
let mut blocks = vec![];
blocks.push(AnthropicSystemBlock {
type_: "text".to_string(),
text: CLAUDE_CODE_SYSTEM_PREFIX.to_string(),
cache_control: Some(AnthropicCacheControl::ephemeral_with_ttl("1h")),
});
validator.validate(
Some(&crate::types::CacheControl::ephemeral_with_ttl("1h")),
CacheContext::system_message(),
);
let msg_count = system_messages.len();
for (i, msg) in system_messages.iter().enumerate() {
if let Some(text) = msg.text() {
let is_last = i == msg_count - 1;
let cache_control = msg.cache_control().cloned().or_else(|| {
if is_last && auto_cache_last {
Some(crate::types::CacheControl::ephemeral_with_ttl("1h"))
} else {
None
}
});
let validated_cache =
validator.validate(cache_control.as_ref(), CacheContext::system_message());
blocks.push(AnthropicSystemBlock {
type_: "text".to_string(),
text,
cache_control: validated_cache.map(|c| AnthropicCacheControl::from(&c)),
});
}
}
return Ok(Some(AnthropicSystemContent::Blocks(blocks)));
}
if !use_blocks {
let combined = system_messages
.iter()
.filter_map(|m| m.text())
.collect::<Vec<_>>()
.join("\n\n");
return Ok(Some(AnthropicSystemContent::String(combined)));
}
let msg_count = system_messages.len();
let blocks: Vec<AnthropicSystemBlock> = system_messages
.iter()
.enumerate()
.filter_map(|(i, msg)| {
let text = msg.text()?;
let is_last = i == msg_count - 1;
let cache_control = msg.cache_control().cloned().or_else(|| {
if is_last && auto_cache_last {
Some(crate::types::CacheControl::ephemeral_with_ttl("1h"))
} else {
None
}
});
let validated_cache =
validator.validate(cache_control.as_ref(), CacheContext::system_message());
Some(AnthropicSystemBlock {
type_: "text".to_string(),
text,
cache_control: validated_cache.map(|c| AnthropicCacheControl::from(&c)),
})
})
.collect();
if blocks.is_empty() {
Ok(None)
} else {
Ok(Some(AnthropicSystemContent::Blocks(blocks)))
}
}
fn build_tools_with_caching(
tools: &Option<Vec<crate::types::Tool>>,
validator: &mut CacheControlValidator,
auto_cache_last: bool,
) -> Result<Option<Vec<serde_json::Value>>> {
let tools = match tools {
Some(t) if !t.is_empty() => t,
_ => return Ok(None),
};
let len = tools.len();
let converted: Vec<serde_json::Value> = tools
.iter()
.enumerate()
.map(|(i, tool)| {
let is_last = i == len - 1;
let cache_control = tool.cache_control().cloned().or_else(|| {
if is_last && auto_cache_last {
Some(crate::types::CacheControl::ephemeral_with_ttl("1h"))
} else {
None
}
});
let validated_cache =
validator.validate(cache_control.as_ref(), CacheContext::tool_definition());
let mut tool_json = json!({
"name": tool.function.name,
"description": tool.function.description,
"input_schema": tool.function.parameters,
});
if let Some(cache) = validated_cache {
tool_json["cache_control"] = json!(AnthropicCacheControl::from(&cache));
}
tool_json
})
.collect();
Ok(Some(converted))
}
fn build_messages_with_caching(
messages: &[Message],
validator: &mut CacheControlValidator,
tail_count: usize,
) -> Result<Vec<AnthropicMessage>> {
let non_system: Vec<&Message> = messages.iter().filter(|m| m.role != Role::System).collect();
let converted: Vec<AnthropicMessage> = non_system
.iter()
.map(|msg| to_anthropic_message_with_caching(msg, validator, false))
.collect::<Result<Vec<_>>>()?;
let mut merged = merge_consecutive_messages(converted);
for msg in &mut merged {
sanitize_anthropic_message(msg);
}
sanitize_message_sequence(&mut merged);
if tail_count > 0 {
let len = merged.len();
let cache_start = len.saturating_sub(tail_count);
for msg in &mut merged[cache_start..] {
if !is_empty_content_message(msg) {
apply_tail_cache_to_message(msg, validator);
}
}
}
Ok(merged)
}
fn apply_tail_cache_to_message(msg: &mut AnthropicMessage, validator: &mut CacheControlValidator) {
let cache = crate::types::CacheControl::ephemeral();
let context = if msg.role == "assistant" {
CacheContext::assistant_message_part()
} else {
CacheContext::user_message_part()
};
let Some(validated_cache) = validator.validate(Some(&cache), context) else {
return; };
let anthropic_cc = AnthropicCacheControl::from(&validated_cache);
match &mut msg.content {
AnthropicMessageContent::Blocks(blocks) => {
if let Some(last) = blocks.last_mut() {
set_block_cache_control(last, Some(anthropic_cc));
}
}
AnthropicMessageContent::String(s) => {
msg.content = AnthropicMessageContent::Blocks(vec![AnthropicContent::Text {
text: std::mem::take(s),
cache_control: Some(anthropic_cc),
}]);
}
}
}
fn is_empty_content_message(msg: &AnthropicMessage) -> bool {
match &msg.content {
AnthropicMessageContent::String(s) => s.is_empty(),
AnthropicMessageContent::Blocks(blocks) => blocks
.iter()
.all(|b| matches!(b, AnthropicContent::Text { text, .. } if text.is_empty())),
}
}
fn sanitize_anthropic_message(msg: &mut AnthropicMessage) {
match &mut msg.content {
AnthropicMessageContent::Blocks(blocks) => {
blocks.retain(
|block| !matches!(block, AnthropicContent::Text { text, .. } if text.is_empty()),
);
for block in blocks.iter_mut() {
if let AnthropicContent::Text {
text,
cache_control,
} = block
&& text.is_empty()
&& cache_control.is_some()
{
*cache_control = None;
}
}
}
AnthropicMessageContent::String(_) => {
}
}
}
fn sanitize_message_sequence(messages: &mut Vec<AnthropicMessage>) {
if messages.is_empty() {
return;
}
patch_tool_result_coverage(messages);
remove_orphan_tool_results(messages);
dedup_tool_results(messages);
remove_empty_content_messages(messages);
let re_merged = merge_consecutive_messages(std::mem::take(messages));
*messages = re_merged;
if messages.first().is_some_and(|m| m.role != "user") {
messages.insert(
0,
AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::String(".".to_string()),
},
);
}
ensure_not_trailing_assistant(messages);
}
fn patch_tool_result_coverage(messages: &mut Vec<AnthropicMessage>) {
let mut i = 0;
while i < messages.len() {
if messages[i].role != "assistant" {
i += 1;
continue;
}
let tool_use_ids = extract_tool_use_ids(&messages[i]);
if tool_use_ids.is_empty() {
i += 1;
continue;
}
let next_is_user = messages.get(i + 1).is_some_and(|m| m.role == "user");
if next_is_user {
let covered_ids = extract_tool_result_ids(&messages[i + 1]);
let missing: Vec<String> = tool_use_ids
.into_iter()
.filter(|id| !covered_ids.contains(id))
.collect();
if !missing.is_empty() {
inject_placeholder_tool_results(&mut messages[i + 1], &missing);
}
} else {
let tool_results: Vec<AnthropicContent> = tool_use_ids
.into_iter()
.map(|id| AnthropicContent::ToolResult {
tool_use_id: id,
content: Some(AnthropicMessageContent::String(
"[Tool call not executed]".to_string(),
)),
is_error: Some(true),
cache_control: None,
})
.collect();
messages.insert(
i + 1,
AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::Blocks(tool_results),
},
);
}
i += 2;
}
}
fn remove_orphan_tool_results(messages: &mut Vec<AnthropicMessage>) {
let mut i = 0;
while i < messages.len() {
if messages[i].role != "user" {
i += 1;
continue;
}
let valid_ids: HashSet<String> = if i > 0 && messages[i - 1].role == "assistant" {
extract_tool_use_ids(&messages[i - 1]).into_iter().collect()
} else {
HashSet::new()
};
if let AnthropicMessageContent::Blocks(blocks) = &mut messages[i].content {
let had_tool_results = blocks
.iter()
.any(|b| matches!(b, AnthropicContent::ToolResult { .. }));
if had_tool_results {
blocks.retain(|block| match block {
AnthropicContent::ToolResult { tool_use_id, .. } => {
valid_ids.contains(tool_use_id)
}
_ => true,
});
}
if blocks.is_empty() {
messages.remove(i);
continue; }
}
i += 1;
}
}
fn dedup_tool_results(messages: &mut [AnthropicMessage]) {
for msg in messages.iter_mut() {
if msg.role != "user" {
continue;
}
if let AnthropicMessageContent::Blocks(blocks) = &mut msg.content {
let has_tool_results = blocks
.iter()
.any(|b| matches!(b, AnthropicContent::ToolResult { .. }));
if !has_tool_results {
continue;
}
let mut last_index: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (i, block) in blocks.iter().enumerate() {
if let AnthropicContent::ToolResult { tool_use_id, .. } = block {
last_index.insert(tool_use_id.clone(), i);
}
}
let mut i = 0;
blocks.retain(|block| {
let keep = match block {
AnthropicContent::ToolResult { tool_use_id, .. } => {
last_index.get(tool_use_id) == Some(&i)
}
_ => true,
};
i += 1;
keep
});
}
}
}
fn remove_empty_content_messages(messages: &mut Vec<AnthropicMessage>) {
messages.retain(|msg| match &msg.content {
AnthropicMessageContent::String(s) => !s.is_empty(),
AnthropicMessageContent::Blocks(blocks) => !blocks.is_empty(),
});
}
fn ensure_not_trailing_assistant(messages: &mut Vec<AnthropicMessage>) {
while messages.last().is_some_and(|m| m.role == "assistant") {
let last = messages.last().expect("checked above");
let tool_use_ids = extract_tool_use_ids(last);
if !tool_use_ids.is_empty() {
let tool_results: Vec<AnthropicContent> = tool_use_ids
.into_iter()
.map(|id| AnthropicContent::ToolResult {
tool_use_id: id,
content: Some(AnthropicMessageContent::String(
"[Tool call interrupted]".to_string(),
)),
is_error: Some(true),
cache_control: None,
})
.collect();
messages.push(AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::Blocks(tool_results),
});
break; }
let is_substantive = match &last.content {
AnthropicMessageContent::String(s) => !s.trim().is_empty(),
AnthropicMessageContent::Blocks(blocks) => blocks.iter().any(|b| match b {
AnthropicContent::Text { text, .. } => !text.trim().is_empty(),
AnthropicContent::Image { .. }
| AnthropicContent::Thinking { .. }
| AnthropicContent::RedactedThinking { .. } => true,
_ => false,
}),
};
if is_substantive {
break;
}
messages.pop();
}
}
fn extract_tool_use_ids(msg: &AnthropicMessage) -> Vec<String> {
match &msg.content {
AnthropicMessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| {
if let AnthropicContent::ToolUse { id, .. } = b {
Some(id.clone())
} else {
None
}
})
.collect(),
_ => vec![],
}
}
fn extract_tool_result_ids(msg: &AnthropicMessage) -> HashSet<String> {
match &msg.content {
AnthropicMessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| {
if let AnthropicContent::ToolResult { tool_use_id, .. } = b {
Some(tool_use_id.clone())
} else {
None
}
})
.collect(),
_ => HashSet::new(),
}
}
fn inject_placeholder_tool_results(msg: &mut AnthropicMessage, missing_ids: &[String]) {
let new_blocks: Vec<AnthropicContent> = missing_ids
.iter()
.map(|id| AnthropicContent::ToolResult {
tool_use_id: id.clone(),
content: Some(AnthropicMessageContent::String(
"[Tool call not executed]".to_string(),
)),
is_error: Some(true),
cache_control: None,
})
.collect();
match &mut msg.content {
AnthropicMessageContent::Blocks(blocks) => {
blocks.extend(new_blocks);
}
AnthropicMessageContent::String(s) => {
let mut blocks = Vec::new();
if !s.is_empty() {
blocks.push(AnthropicContent::Text {
text: std::mem::take(s),
cache_control: None,
});
}
blocks.extend(new_blocks);
msg.content = AnthropicMessageContent::Blocks(blocks);
}
}
}
fn set_block_cache_control(block: &mut AnthropicContent, cc: Option<AnthropicCacheControl>) {
match block {
AnthropicContent::Text { cache_control, .. }
| AnthropicContent::ToolUse { cache_control, .. }
| AnthropicContent::ToolResult { cache_control, .. }
| AnthropicContent::Image { cache_control, .. } => *cache_control = cc,
AnthropicContent::Thinking { .. } | AnthropicContent::RedactedThinking { .. } => {
}
}
}
fn merge_consecutive_messages(messages: Vec<AnthropicMessage>) -> Vec<AnthropicMessage> {
if messages.is_empty() {
return messages;
}
let mut result: Vec<AnthropicMessage> = Vec::with_capacity(messages.len());
for msg in messages {
let should_merge = result.last().is_some_and(|last| last.role == msg.role);
if should_merge {
let Some(last) = result.last_mut() else {
result.push(msg);
continue;
};
let prev = std::mem::take(&mut last.content);
last.content = merge_content(prev, msg.content);
} else {
result.push(msg);
}
}
result
}
fn content_to_blocks(content: AnthropicMessageContent) -> Vec<AnthropicContent> {
match content {
AnthropicMessageContent::Blocks(blocks) => blocks,
AnthropicMessageContent::String(s) => {
vec![AnthropicContent::Text {
text: s,
cache_control: None,
}]
}
}
}
fn merge_content(
a: AnthropicMessageContent,
b: AnthropicMessageContent,
) -> AnthropicMessageContent {
let mut blocks = content_to_blocks(a);
blocks.extend(content_to_blocks(b));
AnthropicMessageContent::Blocks(blocks)
}
fn to_anthropic_message_with_caching(
msg: &Message,
validator: &mut CacheControlValidator,
auto_cache: bool,
) -> Result<AnthropicMessage> {
let role = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "user", Role::System => {
return Err(Error::invalid_response(
"System messages should be filtered out",
));
}
};
let msg_cache_control = msg.cache_control().cloned().or_else(|| {
if auto_cache {
Some(crate::types::CacheControl::ephemeral())
} else {
None
}
});
let parts = msg.parts();
let has_cache_control =
msg_cache_control.is_some() || parts.iter().any(|p| p.cache_control().is_some());
let force_blocks = msg.role == Role::Tool;
let content = if parts.len() == 1 && !has_cache_control && !force_blocks {
match &parts[0] {
ContentPart::Text { text, .. } => AnthropicMessageContent::String(text.clone()),
_ => AnthropicMessageContent::Blocks(vec![to_anthropic_content_part(
&parts[0], None, validator, true,
)?]),
}
} else {
let num_parts = parts.len();
let content_parts = parts
.iter()
.enumerate()
.map(|(i, part)| {
let is_last = i == num_parts - 1;
let fallback_cache = if is_last {
msg_cache_control.as_ref()
} else {
None
};
to_anthropic_content_part(part, fallback_cache, validator, is_last)
})
.collect::<Result<Vec<_>>>()?;
AnthropicMessageContent::Blocks(content_parts)
};
Ok(AnthropicMessage {
role: role.to_string(),
content,
})
}
#[cfg(test)]
fn to_anthropic_message(
msg: &Message,
validator: &mut CacheControlValidator,
) -> Result<AnthropicMessage> {
to_anthropic_message_with_caching(msg, validator, false)
}
fn to_anthropic_content_part(
part: &ContentPart,
fallback_cache: Option<&crate::types::CacheControl>,
validator: &mut CacheControlValidator,
is_last_part: bool,
) -> Result<AnthropicContent> {
let part_cache = part.cache_control();
let effective_cache = if part_cache.is_some() {
part_cache
} else if is_last_part {
fallback_cache
} else {
None
};
match part {
ContentPart::Text { text, .. } => {
let context = CacheContext::user_message_part();
let validated_cache = validator.validate(effective_cache, context);
Ok(AnthropicContent::Text {
text: text.clone(),
cache_control: validated_cache.map(|c| AnthropicCacheControl::from(&c)),
})
}
ContentPart::Image { url, .. } => {
let context = CacheContext::image_content();
let validated_cache = validator.validate(effective_cache, context);
Ok(AnthropicContent::Image {
source: parse_image_source(url)?,
cache_control: validated_cache.map(|c| AnthropicCacheControl::from(&c)),
})
}
ContentPart::ToolCall {
id,
name,
arguments,
..
} => {
let context = CacheContext::assistant_message_part();
let validated_cache = validator.validate(effective_cache, context);
Ok(AnthropicContent::ToolUse {
id: id.clone(),
name: name.clone(),
input: arguments.clone(),
cache_control: validated_cache.map(|c| AnthropicCacheControl::from(&c)),
})
}
ContentPart::ToolResult {
tool_call_id,
content,
..
} => {
let context = CacheContext::tool_result();
let validated_cache = validator.validate(effective_cache, context);
Ok(AnthropicContent::ToolResult {
tool_use_id: tool_call_id.clone(),
content: Some(AnthropicMessageContent::String(content.to_string())),
is_error: None,
cache_control: validated_cache.map(|c| AnthropicCacheControl::from(&c)),
})
}
}
}
fn parse_image_source(url: &str) -> Result<AnthropicSource> {
if url.starts_with("data:") {
let parts: Vec<&str> = url.splitn(2, ',').collect();
if parts.len() != 2 {
return Err(Error::invalid_response("Invalid data URL format"));
}
let media_type = parts[0]
.strip_prefix("data:")
.and_then(|s| s.strip_suffix(";base64"))
.ok_or_else(|| Error::invalid_response("Invalid data URL media type"))?;
Ok(AnthropicSource {
type_: "base64".to_string(),
media_type: media_type.to_string(),
data: parts[1].to_string(),
})
} else {
Err(Error::invalid_response(
"Anthropic requires base64-encoded images, not URLs",
))
}
}
pub fn from_anthropic_response_with_warnings(
resp: AnthropicResponse,
warnings: Vec<CacheWarning>,
) -> Result<GenerateResponse> {
use crate::types::{ResponseWarning, ToolCall};
let content: Vec<ResponseContent> = resp
.content
.iter()
.filter_map(|c| match c {
AnthropicContent::Text { text, .. } => {
Some(ResponseContent::Text { text: text.clone() })
}
AnthropicContent::Thinking { thinking, .. } => Some(ResponseContent::Reasoning {
reasoning: thinking.clone(),
}),
AnthropicContent::ToolUse {
id, name, input, ..
} => Some(ResponseContent::ToolCall(ToolCall {
id: id.clone(),
name: name.clone(),
arguments: input.clone(),
metadata: None,
})),
_ => None,
})
.collect();
if content.is_empty() {
return Err(Error::invalid_response("No content in response"));
}
let finish_reason = if content
.iter()
.any(|c| matches!(c, ResponseContent::ToolCall(_)))
{
FinishReason::with_raw(FinishReasonKind::ToolCalls, "tool_use")
} else {
parse_stop_reason(&resp.stop_reason)
};
let cache_creation = resp.usage.cache_creation_input_tokens.unwrap_or(0);
let cache_read = resp.usage.cache_read_input_tokens.unwrap_or(0);
let input_tokens = resp.usage.input_tokens;
let output_tokens = resp.usage.output_tokens;
let total_input = input_tokens + cache_creation + cache_read;
let usage = Usage::with_details(
InputTokenDetails {
total: Some(total_input),
no_cache: Some(input_tokens),
cache_read: if cache_read > 0 {
Some(cache_read)
} else {
None
},
cache_write: if cache_creation > 0 {
Some(cache_creation)
} else {
None
},
},
OutputTokenDetails {
total: Some(output_tokens),
text: None, reasoning: None, },
Some(serde_json::to_value(&resp.usage).unwrap_or_default()),
);
let response_warnings: Option<Vec<ResponseWarning>> = if warnings.is_empty() {
None
} else {
Some(warnings.into_iter().map(ResponseWarning::from).collect())
};
Ok(GenerateResponse {
content,
usage,
finish_reason,
metadata: Some(json!({
"id": resp.id,
"model": resp.model,
})),
warnings: response_warnings,
})
}
fn parse_stop_reason(reason: &Option<String>) -> FinishReason {
match reason.as_deref() {
Some("end_turn") => FinishReason::with_raw(FinishReasonKind::Stop, "end_turn"),
Some("max_tokens") => FinishReason::with_raw(FinishReasonKind::Length, "max_tokens"),
Some("stop_sequence") => FinishReason::with_raw(FinishReasonKind::Stop, "stop_sequence"),
Some("tool_use") => FinishReason::with_raw(FinishReasonKind::ToolCalls, "tool_use"),
Some(raw) => FinishReason::with_raw(FinishReasonKind::Other, raw),
None => FinishReason::other(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::MessageContent;
#[test]
fn test_infer_max_tokens() {
assert_eq!(infer_max_tokens("claude-opus-4-5"), 64000);
assert_eq!(infer_max_tokens("claude-sonnet-4"), 64000);
assert_eq!(infer_max_tokens("claude-opus-4"), 32000);
assert_eq!(infer_max_tokens("claude-3-5-sonnet"), 8192);
assert_eq!(infer_max_tokens("claude-3-opus"), 4096);
}
#[test]
fn test_parse_image_source() {
let data_url = "data:image/png;base64,iVBORw0KGgoAAAANS";
let result = parse_image_source(data_url).unwrap();
assert_eq!(result.type_, "base64");
assert_eq!(result.media_type, "image/png");
assert_eq!(result.data, "iVBORw0KGgoAAAANS");
}
#[test]
fn test_tool_role_message_converted_to_user_with_tool_result() {
let mut validator = CacheControlValidator::new();
let tool_msg = Message {
role: Role::Tool,
content: MessageContent::Parts(vec![ContentPart::ToolResult {
tool_call_id: "toolu_01Abc123".to_string(),
content: serde_json::json!("Tool execution result"),
provider_options: None,
}]),
name: None,
provider_options: None,
};
let result = to_anthropic_message(&tool_msg, &mut validator).unwrap();
assert_eq!(
result.role, "user",
"Tool role should be converted to user for Anthropic"
);
match result.content {
AnthropicMessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1, "Should have exactly one content block");
match &blocks[0] {
AnthropicContent::ToolResult {
tool_use_id,
content,
..
} => {
assert_eq!(tool_use_id, "toolu_01Abc123");
match content {
Some(AnthropicMessageContent::String(s)) => {
assert_eq!(s, "\"Tool execution result\"");
}
_ => panic!("Expected string content in tool result"),
}
}
_ => panic!("Expected ToolResult content block, got {:?}", blocks[0]),
}
}
_ => panic!("Expected Blocks content, got {:?}", result.content),
}
}
#[test]
fn test_tool_role_message_with_text_content() {
let mut validator = CacheControlValidator::new();
let tool_msg = Message {
role: Role::Tool,
content: MessageContent::Parts(vec![ContentPart::ToolResult {
tool_call_id: "toolu_02Xyz789".to_string(),
content: serde_json::json!({"temperature": 22, "unit": "celsius"}),
provider_options: None,
}]),
name: None,
provider_options: None,
};
let result = to_anthropic_message(&tool_msg, &mut validator).unwrap();
assert_eq!(result.role, "user");
match result.content {
AnthropicMessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
match &blocks[0] {
AnthropicContent::ToolResult {
tool_use_id,
content,
..
} => {
assert_eq!(tool_use_id, "toolu_02Xyz789");
match content {
Some(AnthropicMessageContent::String(s)) => {
assert!(s.contains("temperature"));
assert!(s.contains("22"));
}
_ => panic!("Expected string content"),
}
}
_ => panic!("Expected ToolResult"),
}
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_assistant_message_not_affected_by_tool_conversion() {
let mut validator = CacheControlValidator::new();
let assistant_msg = Message {
role: Role::Assistant,
content: MessageContent::Text("I'll help you with that.".to_string()),
name: None,
provider_options: None,
};
let result = to_anthropic_message(&assistant_msg, &mut validator).unwrap();
assert_eq!(result.role, "assistant");
match result.content {
AnthropicMessageContent::String(s) => {
assert_eq!(s, "I'll help you with that.");
}
_ => panic!("Expected string content for simple assistant message"),
}
}
#[test]
fn test_user_message_not_affected_by_tool_conversion() {
let mut validator = CacheControlValidator::new();
let user_msg = Message {
role: Role::User,
content: MessageContent::Text("Hello!".to_string()),
name: None,
provider_options: None,
};
let result = to_anthropic_message(&user_msg, &mut validator).unwrap();
assert_eq!(result.role, "user");
match result.content {
AnthropicMessageContent::String(s) => {
assert_eq!(s, "Hello!");
}
_ => panic!("Expected string content for simple user message"),
}
}
fn user_msg(text: &str) -> AnthropicMessage {
AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::String(text.to_string()),
}
}
fn assistant_msg(text: &str) -> AnthropicMessage {
AnthropicMessage {
role: "assistant".to_string(),
content: AnthropicMessageContent::String(text.to_string()),
}
}
fn user_blocks_msg(blocks: Vec<AnthropicContent>) -> AnthropicMessage {
AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::Blocks(blocks),
}
}
fn assistant_blocks_msg(blocks: Vec<AnthropicContent>) -> AnthropicMessage {
AnthropicMessage {
role: "assistant".to_string(),
content: AnthropicMessageContent::Blocks(blocks),
}
}
fn text_block(text: &str) -> AnthropicContent {
AnthropicContent::Text {
text: text.to_string(),
cache_control: None,
}
}
fn tool_result_block(tool_use_id: &str, content: &str) -> AnthropicContent {
AnthropicContent::ToolResult {
tool_use_id: tool_use_id.to_string(),
content: Some(AnthropicMessageContent::String(content.to_string())),
is_error: None,
cache_control: None,
}
}
fn tool_use_block(id: &str, name: &str) -> AnthropicContent {
AnthropicContent::ToolUse {
id: id.to_string(),
name: name.to_string(),
input: serde_json::json!({}),
cache_control: None,
}
}
fn count_blocks(content: &AnthropicMessageContent) -> usize {
match content {
AnthropicMessageContent::String(_) => 1,
AnthropicMessageContent::Blocks(b) => b.len(),
}
}
#[test]
fn test_merge_consecutive_user_messages() {
let messages = vec![user_msg("Hello"), user_msg("World")];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].role, "user");
assert_eq!(count_blocks(&merged[0].content), 2);
}
#[test]
fn test_merge_consecutive_tool_result_messages() {
let messages = vec![
user_blocks_msg(vec![tool_result_block("t1", "result1")]),
user_blocks_msg(vec![tool_result_block("t2", "result2")]),
user_blocks_msg(vec![tool_result_block("t3", "result3")]),
];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].role, "user");
assert_eq!(count_blocks(&merged[0].content), 3);
if let AnthropicMessageContent::Blocks(blocks) = &merged[0].content {
for (i, block) in blocks.iter().enumerate() {
match block {
AnthropicContent::ToolResult { tool_use_id, .. } => {
assert_eq!(tool_use_id, &format!("t{}", i + 1));
}
_ => panic!("Expected ToolResult block at index {}", i),
}
}
} else {
panic!("Expected Blocks content");
}
}
#[test]
fn test_merge_consecutive_assistant_messages() {
let messages = vec![assistant_msg("Part 1"), assistant_msg("Part 2")];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].role, "assistant");
assert_eq!(count_blocks(&merged[0].content), 2);
}
#[test]
fn test_no_merge_alternating_roles() {
let messages = vec![user_msg("Hi"), assistant_msg("Hello"), user_msg("Bye")];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 3);
assert_eq!(merged[0].role, "user");
assert_eq!(merged[1].role, "assistant");
assert_eq!(merged[2].role, "user");
}
#[test]
fn test_merge_mixed_string_and_blocks() {
let messages = vec![
user_msg("Hello"),
user_blocks_msg(vec![text_block("World")]),
];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 1);
assert_eq!(count_blocks(&merged[0].content), 2);
if let AnthropicMessageContent::Blocks(blocks) = &merged[0].content {
match &blocks[0] {
AnthropicContent::Text { text, .. } => assert_eq!(text, "Hello"),
_ => panic!("Expected Text block"),
}
match &blocks[1] {
AnthropicContent::Text { text, .. } => assert_eq!(text, "World"),
_ => panic!("Expected Text block"),
}
} else {
panic!("Expected Blocks content");
}
}
#[test]
fn test_merge_preserves_cache_control_on_last() {
let cached_block = AnthropicContent::Text {
text: "cached".to_string(),
cache_control: Some(AnthropicCacheControl::ephemeral()),
};
let messages = vec![user_msg("first"), user_blocks_msg(vec![cached_block])];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 1);
if let AnthropicMessageContent::Blocks(blocks) = &merged[0].content {
assert_eq!(blocks.len(), 2);
match &blocks[0] {
AnthropicContent::Text { cache_control, .. } => {
assert!(cache_control.is_none());
}
_ => panic!("Expected Text block"),
}
match &blocks[1] {
AnthropicContent::Text { cache_control, .. } => {
assert!(cache_control.is_some());
}
_ => panic!("Expected Text block"),
}
} else {
panic!("Expected Blocks content");
}
}
#[test]
fn test_single_message_no_merge() {
let messages = vec![user_msg("solo")];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].role, "user");
match &merged[0].content {
AnthropicMessageContent::String(s) => assert_eq!(s, "solo"),
_ => panic!("Expected String content for single message"),
}
}
#[test]
fn test_empty_messages() {
let messages: Vec<AnthropicMessage> = vec![];
let merged = merge_consecutive_messages(messages);
assert!(merged.is_empty());
}
#[test]
fn test_full_conversation_with_multiple_tool_results() {
let messages = vec![
assistant_blocks_msg(vec![
tool_use_block("t1", "tool_a"),
tool_use_block("t2", "tool_b"),
tool_use_block("t3", "tool_c"),
]),
user_blocks_msg(vec![tool_result_block("t1", "result_a")]),
user_blocks_msg(vec![tool_result_block("t2", "result_b")]),
user_blocks_msg(vec![tool_result_block("t3", "result_c")]),
];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].role, "assistant");
assert_eq!(merged[1].role, "user");
assert_eq!(count_blocks(&merged[0].content), 3);
assert_eq!(count_blocks(&merged[1].content), 3);
if let AnthropicMessageContent::Blocks(blocks) = &merged[1].content {
for block in blocks {
assert!(
matches!(block, AnthropicContent::ToolResult { .. }),
"Expected ToolResult block"
);
}
}
}
#[test]
fn test_user_message_followed_by_tool_results_merges() {
let messages = vec![
user_msg("Here are the results:"),
user_blocks_msg(vec![tool_result_block("t1", "result1")]),
user_blocks_msg(vec![tool_result_block("t2", "result2")]),
];
let merged = merge_consecutive_messages(messages);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].role, "user");
assert_eq!(count_blocks(&merged[0].content), 3);
if let AnthropicMessageContent::Blocks(blocks) = &merged[0].content {
match &blocks[0] {
AnthropicContent::Text { text, .. } => {
assert_eq!(text, "Here are the results:");
}
_ => panic!("Expected Text block first"),
}
assert!(matches!(&blocks[1], AnthropicContent::ToolResult { .. }));
assert!(matches!(&blocks[2], AnthropicContent::ToolResult { .. }));
}
}
#[test]
fn test_apply_tail_cache_to_string_message() {
let mut validator = CacheControlValidator::new();
let mut msg = user_msg("hello");
apply_tail_cache_to_message(&mut msg, &mut validator);
match &msg.content {
AnthropicMessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
match &blocks[0] {
AnthropicContent::Text {
text,
cache_control,
} => {
assert_eq!(text, "hello");
assert!(cache_control.is_some());
}
_ => panic!("Expected Text block"),
}
}
_ => panic!("Expected Blocks content after tail cache"),
}
assert_eq!(validator.breakpoint_count(), 1);
}
#[test]
fn test_apply_tail_cache_to_blocks_message() {
let mut validator = CacheControlValidator::new();
let mut msg = user_blocks_msg(vec![
tool_result_block("t1", "result1"),
tool_result_block("t2", "result2"),
]);
apply_tail_cache_to_message(&mut msg, &mut validator);
if let AnthropicMessageContent::Blocks(blocks) = &msg.content {
assert_eq!(blocks.len(), 2);
match &blocks[0] {
AnthropicContent::ToolResult { cache_control, .. } => {
assert!(cache_control.is_none(), "First block should NOT be cached");
}
_ => panic!("Expected ToolResult"),
}
match &blocks[1] {
AnthropicContent::ToolResult { cache_control, .. } => {
assert!(cache_control.is_some(), "Last block SHOULD be cached");
}
_ => panic!("Expected ToolResult"),
}
} else {
panic!("Expected Blocks content");
}
assert_eq!(validator.breakpoint_count(), 1);
}
#[test]
fn test_apply_tail_cache_respects_breakpoint_limit() {
let mut validator = CacheControlValidator::new();
let cache = crate::types::CacheControl::ephemeral();
for _ in 0..4 {
validator.validate(Some(&cache), CacheContext::user_message_part());
}
assert!(validator.is_at_limit());
let mut msg = user_msg("no room");
apply_tail_cache_to_message(&mut msg, &mut validator);
match &msg.content {
AnthropicMessageContent::String(s) => assert_eq!(s, "no room"),
_ => panic!("Should not convert to blocks when breakpoint limit exceeded"),
}
}
#[test]
fn test_tail_cache_after_merge_uses_one_breakpoint_for_merged_tool_results() {
let mut validator = CacheControlValidator::new();
let mut merged = [
assistant_blocks_msg(vec![
tool_use_block("t1", "tool_a"),
tool_use_block("t2", "tool_b"),
tool_use_block("t3", "tool_c"),
]),
user_blocks_msg(vec![
tool_result_block("t1", "result_a"),
tool_result_block("t2", "result_b"),
tool_result_block("t3", "result_c"),
]),
];
let len = merged.len();
let cache_start = len.saturating_sub(2);
for msg in &mut merged[cache_start..] {
apply_tail_cache_to_message(msg, &mut validator);
}
assert_eq!(
validator.breakpoint_count(),
2,
"Should use 2 breakpoints, not more"
);
if let AnthropicMessageContent::Blocks(blocks) = &merged[0].content {
match &blocks[2] {
AnthropicContent::ToolUse { cache_control, .. } => {
assert!(cache_control.is_some(), "Last tool_use should be cached");
}
_ => panic!("Expected ToolUse"),
}
for block in &blocks[..2] {
match block {
AnthropicContent::ToolUse { cache_control, .. } => {
assert!(cache_control.is_none());
}
_ => panic!("Expected ToolUse"),
}
}
}
if let AnthropicMessageContent::Blocks(blocks) = &merged[1].content {
match &blocks[2] {
AnthropicContent::ToolResult { cache_control, .. } => {
assert!(cache_control.is_some(), "Last tool_result should be cached");
}
_ => panic!("Expected ToolResult"),
}
for block in &blocks[..2] {
match block {
AnthropicContent::ToolResult { cache_control, .. } => {
assert!(cache_control.is_none());
}
_ => panic!("Expected ToolResult"),
}
}
}
}
#[test]
fn test_set_block_cache_control() {
let cc = AnthropicCacheControl::ephemeral();
let mut block = text_block("hello");
set_block_cache_control(&mut block, Some(cc.clone()));
match &block {
AnthropicContent::Text { cache_control, .. } => assert!(cache_control.is_some()),
_ => panic!("Expected Text"),
}
let mut block = tool_result_block("t1", "result");
set_block_cache_control(&mut block, Some(cc.clone()));
match &block {
AnthropicContent::ToolResult { cache_control, .. } => {
assert!(cache_control.is_some())
}
_ => panic!("Expected ToolResult"),
}
let mut block = tool_use_block("t1", "tool_a");
set_block_cache_control(&mut block, Some(cc.clone()));
match &block {
AnthropicContent::ToolUse { cache_control, .. } => assert!(cache_control.is_some()),
_ => panic!("Expected ToolUse"),
}
}
#[test]
fn test_sanitize_removes_empty_text_blocks() {
let cc = AnthropicCacheControl::ephemeral();
let mut msg = user_blocks_msg(vec![AnthropicContent::Text {
text: String::new(),
cache_control: Some(cc.clone()),
}]);
sanitize_anthropic_message(&mut msg);
match &msg.content {
AnthropicMessageContent::Blocks(blocks) => {
assert!(
blocks.is_empty(),
"Empty text blocks must be removed entirely"
);
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_sanitize_preserves_cache_control_on_non_empty_text() {
let cc = AnthropicCacheControl::ephemeral();
let mut msg = user_blocks_msg(vec![AnthropicContent::Text {
text: "hello".to_string(),
cache_control: Some(cc.clone()),
}]);
sanitize_anthropic_message(&mut msg);
match &msg.content {
AnthropicMessageContent::Blocks(blocks) => match &blocks[0] {
AnthropicContent::Text { cache_control, .. } => {
assert!(
cache_control.is_some(),
"cache_control must be preserved on non-empty text"
);
}
_ => panic!("Expected Text block"),
},
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_sanitize_handles_mixed_blocks() {
let cc = AnthropicCacheControl::ephemeral();
let mut msg = user_blocks_msg(vec![
AnthropicContent::Text {
text: String::new(),
cache_control: Some(cc.clone()),
},
AnthropicContent::Text {
text: "real content".to_string(),
cache_control: Some(cc.clone()),
},
AnthropicContent::ToolResult {
tool_use_id: "t1".to_string(),
content: Some(AnthropicMessageContent::String("ok".to_string())),
is_error: None,
cache_control: Some(cc.clone()),
},
]);
sanitize_anthropic_message(&mut msg);
match &msg.content {
AnthropicMessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2);
match &blocks[0] {
AnthropicContent::Text {
text,
cache_control,
} => {
assert_eq!(text, "real content");
assert!(cache_control.is_some());
}
_ => panic!("Expected Text"),
}
match &blocks[1] {
AnthropicContent::ToolResult { cache_control, .. } => {
assert!(cache_control.is_some());
}
_ => panic!("Expected ToolResult"),
}
}
_ => panic!("Expected Blocks"),
}
}
#[test]
fn test_sanitize_noop_on_string_content() {
let mut msg = user_msg("hello");
sanitize_anthropic_message(&mut msg);
match &msg.content {
AnthropicMessageContent::String(s) => assert_eq!(s, "hello"),
_ => panic!("Expected String content"),
}
}
#[test]
fn test_is_empty_content_message() {
assert!(is_empty_content_message(&user_msg("")));
assert!(!is_empty_content_message(&user_msg("hello")));
assert!(is_empty_content_message(&user_blocks_msg(vec![
text_block(""),
])));
assert!(!is_empty_content_message(&user_blocks_msg(vec![
text_block("hello"),
])));
assert!(!is_empty_content_message(&user_blocks_msg(vec![
tool_result_block("t1", "result"),
])));
assert!(!is_empty_content_message(&user_blocks_msg(vec![
text_block(""),
tool_result_block("t1", "result"),
])));
}
#[test]
fn test_empty_message_does_not_waste_cache_breakpoint() {
let mut validator = CacheControlValidator::new();
let mut messages = vec![
user_msg("real content"),
assistant_msg(""), user_msg("more content"),
];
for msg in &mut messages {
if !is_empty_content_message(msg) {
apply_tail_cache_to_message(msg, &mut validator);
}
}
assert_eq!(
validator.breakpoint_count(),
2,
"Empty message must not consume a cache breakpoint"
);
}
#[test]
fn test_sanitize_sequence_adds_missing_tool_results() {
let mut messages = vec![
user_msg("Hello"),
assistant_blocks_msg(vec![
tool_use_block("t1", "tool_a"),
tool_use_block("t2", "tool_b"),
tool_use_block("t3", "tool_c"),
]),
user_blocks_msg(vec![tool_result_block("t1", "result_a")]),
];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[2].role, "user");
let result_ids = extract_tool_result_ids(&messages[2]);
assert!(result_ids.contains("t1"), "Original tool_result preserved");
assert!(result_ids.contains("t2"), "Placeholder added for t2");
assert!(result_ids.contains("t3"), "Placeholder added for t3");
}
#[test]
fn test_sanitize_sequence_inserts_user_for_dangling_tool_use() {
let mut messages = vec![
user_msg("Hello"),
assistant_blocks_msg(vec![
tool_use_block("t1", "tool_a"),
tool_use_block("t2", "tool_b"),
]),
];
sanitize_message_sequence(&mut messages);
assert!(messages.last().is_some_and(|m| m.role == "user"));
let last = messages.last().expect("non-empty");
let result_ids = extract_tool_result_ids(last);
assert!(result_ids.contains("t1"));
assert!(result_ids.contains("t2"));
}
#[test]
fn test_sanitize_sequence_preserves_trailing_assistant_with_substantive_text() {
let mut messages = vec![user_msg("Hello"), assistant_msg("I'll help you with that.")];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
}
#[test]
fn test_sanitize_sequence_removes_trailing_assistant_empty_text() {
let mut messages = vec![user_msg("Hello"), assistant_msg(" ")];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "user");
}
#[test]
fn test_sanitize_sequence_trailing_assistant_with_tool_use() {
let mut messages = vec![
user_msg("Hello"),
assistant_blocks_msg(vec![
text_block("Let me check..."),
tool_use_block("t1", "search"),
]),
];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[2].role, "user");
let result_ids = extract_tool_result_ids(&messages[2]);
assert!(result_ids.contains("t1"));
}
#[test]
fn test_sanitize_sequence_removes_orphan_tool_results() {
let mut messages = vec![
user_msg("Hello"),
assistant_msg("Sure!"), user_blocks_msg(vec![
tool_result_block("orphan_id", "stale result"),
text_block("Follow-up text"),
]),
];
sanitize_message_sequence(&mut messages);
let last = messages.last().expect("non-empty");
assert_eq!(last.role, "user");
let result_ids = extract_tool_result_ids(last);
assert!(
result_ids.is_empty(),
"Orphan tool_result should be removed"
);
if let AnthropicMessageContent::Blocks(blocks) = &last.content {
assert!(blocks.iter().any(
|b| matches!(b, AnthropicContent::Text { text, .. } if text == "Follow-up text")
));
} else {
panic!("Expected Blocks content");
}
}
#[test]
fn test_sanitize_sequence_removes_user_with_only_orphan_results() {
let mut messages = vec![
user_msg("Hello"),
assistant_msg("Sure!"), user_blocks_msg(vec![tool_result_block("orphan_id", "stale result")]),
];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
}
#[test]
fn test_sanitize_sequence_ensures_starts_with_user() {
let mut messages = vec![assistant_msg("Hello"), user_msg("Hi")];
sanitize_message_sequence(&mut messages);
assert_eq!(messages[0].role, "user");
}
#[test]
fn test_sanitize_sequence_noop_for_valid_conversation() {
let mut messages = vec![
user_msg("Hello"),
assistant_msg("Hi there!"),
user_msg("How are you?"),
];
let original_len = messages.len();
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), original_len);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[2].role, "user");
}
#[test]
fn test_sanitize_sequence_full_tool_call_flow() {
let mut messages = vec![
user_msg("Run all checks"),
assistant_blocks_msg(vec![
tool_use_block("t1", "check_a"),
tool_use_block("t2", "check_b"),
tool_use_block("t3", "check_c"),
tool_use_block("t4", "check_d"),
tool_use_block("t5", "check_e"),
tool_use_block("t6", "check_f"),
]),
user_blocks_msg(vec![
tool_result_block("t1", "ok"),
tool_result_block("t2", "ok"),
tool_result_block("t3", "ok"),
tool_result_block("t4", "ok"),
tool_result_block("t5", "ok"),
tool_result_block("t6", "ok"),
]),
];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 3);
let result_ids = extract_tool_result_ids(&messages[2]);
assert_eq!(result_ids.len(), 6);
}
#[test]
fn test_sanitize_sequence_partial_tool_results_missing() {
let mut messages = vec![
user_msg("Run all checks"),
assistant_blocks_msg(vec![
tool_use_block("t1", "check_a"),
tool_use_block("t2", "check_b"),
tool_use_block("t3", "check_c"),
tool_use_block("t4", "check_d"),
tool_use_block("t5", "check_e"),
tool_use_block("t6", "check_f"),
]),
user_msg("Continue"), ];
sanitize_message_sequence(&mut messages);
assert_eq!(messages[2].role, "user");
let result_ids = extract_tool_result_ids(&messages[2]);
assert_eq!(
result_ids.len(),
6,
"All 6 missing tool_results should have placeholders"
);
}
#[test]
fn test_sanitize_sequence_empty_messages() {
let mut messages: Vec<AnthropicMessage> = vec![];
sanitize_message_sequence(&mut messages);
assert!(messages.is_empty());
}
#[test]
fn test_sanitize_sequence_multiple_consecutive_trailing_assistants_substantive() {
let mut messages = vec![
user_msg("Hello"),
assistant_msg("Part 1"),
assistant_msg("Part 2"),
];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
}
#[test]
fn test_sanitize_sequence_multiple_consecutive_trailing_assistants_empty() {
let mut messages = vec![user_msg("Hello"), assistant_msg(""), assistant_msg(" ")];
sanitize_message_sequence(&mut messages);
assert!(
messages.last().is_some_and(|m| m.role == "user"),
"Must end with user after empty trailing assistants removed"
);
}
#[test]
fn test_extract_tool_use_ids() {
let msg = assistant_blocks_msg(vec![
text_block("I'll run some tools"),
tool_use_block("t1", "search"),
tool_use_block("t2", "fetch"),
]);
let ids = extract_tool_use_ids(&msg);
assert_eq!(ids, vec!["t1", "t2"]);
}
#[test]
fn test_extract_tool_use_ids_from_string_content() {
let msg = assistant_msg("No tools here");
let ids = extract_tool_use_ids(&msg);
assert!(ids.is_empty());
}
#[test]
fn test_extract_tool_result_ids() {
let msg = user_blocks_msg(vec![
tool_result_block("t1", "result1"),
tool_result_block("t2", "result2"),
text_block("Some text"),
]);
let ids = extract_tool_result_ids(&msg);
assert_eq!(ids.len(), 2);
assert!(ids.contains("t1"));
assert!(ids.contains("t2"));
}
#[test]
fn test_inject_placeholder_tool_results_into_blocks() {
let mut msg = user_blocks_msg(vec![tool_result_block("t1", "result1")]);
inject_placeholder_tool_results(&mut msg, &["t2".to_string(), "t3".to_string()]);
if let AnthropicMessageContent::Blocks(blocks) = &msg.content {
assert_eq!(blocks.len(), 3);
assert!(
matches!(&blocks[0], AnthropicContent::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
);
assert!(
matches!(&blocks[1], AnthropicContent::ToolResult { tool_use_id, is_error, .. } if tool_use_id == "t2" && *is_error == Some(true))
);
assert!(
matches!(&blocks[2], AnthropicContent::ToolResult { tool_use_id, .. } if tool_use_id == "t3")
);
} else {
panic!("Expected Blocks");
}
}
#[test]
fn test_inject_placeholder_tool_results_into_string() {
let mut msg = user_msg("Continue");
inject_placeholder_tool_results(&mut msg, &["t1".to_string()]);
if let AnthropicMessageContent::Blocks(blocks) = &msg.content {
assert_eq!(blocks.len(), 2);
assert!(
matches!(&blocks[0], AnthropicContent::Text { text, .. } if text == "Continue")
);
assert!(
matches!(&blocks[1], AnthropicContent::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
);
} else {
panic!("Expected Blocks");
}
}
#[test]
fn test_inject_placeholder_into_empty_string_skips_empty_text_block() {
let mut msg = AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::String(String::new()),
};
inject_placeholder_tool_results(&mut msg, &["t1".to_string()]);
if let AnthropicMessageContent::Blocks(blocks) = &msg.content {
assert_eq!(
blocks.len(),
1,
"Empty string should not produce a text block"
);
assert!(
matches!(&blocks[0], AnthropicContent::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
);
} else {
panic!("Expected Blocks");
}
}
#[test]
fn test_sanitize_sequence_distant_orphan_tool_result() {
let mut messages = vec![
assistant_blocks_msg(vec![tool_use_block("t1", "search")]),
user_msg("Intermediate text"), assistant_msg("I found something"),
user_blocks_msg(vec![tool_result_block("t1", "late result")]), ];
sanitize_message_sequence(&mut messages);
assert_eq!(messages[0].role, "user");
let assistant_idx = messages
.iter()
.position(|m| m.role == "assistant" && !extract_tool_use_ids(m).is_empty())
.expect("Should have assistant with tool_use");
let next = &messages[assistant_idx + 1];
assert_eq!(next.role, "user");
let result_ids = extract_tool_result_ids(next);
assert!(
result_ids.contains("t1"),
"tool_result for t1 must follow its tool_use"
);
let last = messages.last().expect("non-empty");
assert!(
last.role == "user" || last.role == "assistant",
"Must end with user or substantive assistant"
);
for msg in &messages[(assistant_idx + 2)..] {
let orphans = extract_tool_result_ids(msg);
assert!(
!orphans.contains("t1"),
"Orphan tool_result(t1) should be removed from later messages"
);
}
}
#[test]
fn test_sanitize_sequence_context_manager_truncated_results() {
let mut messages = vec![
user_msg("Start"),
assistant_blocks_msg(vec![
tool_use_block("t1", "search"),
tool_use_block("t2", "fetch"),
]),
user_blocks_msg(vec![
tool_result_block("t1", "ok"),
tool_result_block("t2", "ok"),
]),
assistant_blocks_msg(vec![
tool_use_block("t3", "analyze"),
tool_use_block("t4", "summarize"),
]),
user_msg("What did you find?"),
assistant_msg("Based on my analysis..."),
];
sanitize_message_sequence(&mut messages);
let assistant_idx = messages
.iter()
.position(|m| {
m.role == "assistant"
&& extract_tool_use_ids(m)
.iter()
.any(|id| id == "t3" || id == "t4")
})
.expect("Should find assistant with t3/t4");
let next = &messages[assistant_idx + 1];
assert_eq!(next.role, "user");
let result_ids = extract_tool_result_ids(next);
assert!(result_ids.contains("t3"), "Placeholder for t3");
assert!(result_ids.contains("t4"), "Placeholder for t4");
assert_eq!(messages[0].role, "user", "Must start with user");
let last = messages.last().expect("non-empty");
assert!(
last.role == "user" || last.role == "assistant",
"Must end with user or substantive assistant"
);
for window in messages.windows(2) {
assert_ne!(
window[0].role, window[1].role,
"Roles must alternate: {:?} followed by {:?}",
window[0].role, window[1].role
);
}
}
#[test]
fn test_sanitize_sequence_preserves_valid_tool_results() {
let mut messages = vec![
user_msg("Hello"),
assistant_blocks_msg(vec![
tool_use_block("t1", "search"),
tool_use_block("t2", "fetch"),
]),
user_blocks_msg(vec![
tool_result_block("t1", "found it"),
tool_result_block("t2", "fetched it"),
]),
assistant_msg("Here's what I found"),
user_msg("Thanks"),
];
let original_len = messages.len();
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), original_len);
let result_ids = extract_tool_result_ids(&messages[2]);
assert!(result_ids.contains("t1"));
assert!(result_ids.contains("t2"));
}
#[test]
fn test_dedup_tool_results_removes_duplicates() {
let mut messages = vec![
user_msg("Hi"),
assistant_blocks_msg(vec![tool_use_block("t1", "test_tool")]),
user_blocks_msg(vec![
tool_result_block("t1", "first"),
tool_result_block("t1", "second"),
]),
];
dedup_tool_results(&mut messages);
if let AnthropicMessageContent::Blocks(blocks) = &messages[2].content {
let results: Vec<_> = blocks
.iter()
.filter(|b| matches!(b, AnthropicContent::ToolResult { .. }))
.collect();
assert_eq!(results.len(), 1, "Should keep only one tool_result per ID");
match &results[0] {
AnthropicContent::ToolResult { content, .. } => match content {
Some(AnthropicMessageContent::String(s)) => {
assert_eq!(s, "second", "Should keep last result");
}
_ => panic!("Expected string content"),
},
_ => panic!("Expected ToolResult"),
}
} else {
panic!("Expected Blocks");
}
}
#[test]
fn test_dedup_tool_results_preserves_different_ids() {
let mut messages = vec![user_blocks_msg(vec![
tool_result_block("t1", "result1"),
tool_result_block("t2", "result2"),
])];
dedup_tool_results(&mut messages);
if let AnthropicMessageContent::Blocks(blocks) = &messages[0].content {
assert_eq!(blocks.len(), 2, "Different IDs should both be kept");
}
}
#[test]
fn test_dedup_tool_results_preserves_non_tool_blocks() {
let mut messages = vec![user_blocks_msg(vec![
text_block("hello"),
tool_result_block("t1", "first"),
tool_result_block("t1", "second"),
text_block("world"),
])];
dedup_tool_results(&mut messages);
if let AnthropicMessageContent::Blocks(blocks) = &messages[0].content {
assert_eq!(blocks.len(), 3, "2 text blocks + 1 deduped tool_result");
assert!(matches!(&blocks[0], AnthropicContent::Text { text, .. } if text == "hello"));
assert!(matches!(&blocks[1], AnthropicContent::ToolResult { .. }));
assert!(matches!(&blocks[2], AnthropicContent::Text { text, .. } if text == "world"));
}
}
#[test]
fn test_dedup_skips_assistant_messages() {
let mut messages = vec![assistant_blocks_msg(vec![
tool_use_block("t1", "a"),
tool_use_block("t1", "a"), ])];
let original_len = match &messages[0].content {
AnthropicMessageContent::Blocks(b) => b.len(),
_ => panic!(),
};
dedup_tool_results(&mut messages);
match &messages[0].content {
AnthropicMessageContent::Blocks(b) => assert_eq!(b.len(), original_len),
_ => panic!(),
}
}
#[test]
fn test_remove_empty_string_content() {
let mut messages = vec![user_msg("Hello"), assistant_msg(""), user_msg("World")];
remove_empty_content_messages(&mut messages);
assert_eq!(messages.len(), 2);
match &messages[0].content {
AnthropicMessageContent::String(s) => assert_eq!(s, "Hello"),
_ => panic!(),
}
match &messages[1].content {
AnthropicMessageContent::String(s) => assert_eq!(s, "World"),
_ => panic!(),
}
}
#[test]
fn test_remove_empty_blocks_content() {
let mut messages = vec![
user_msg("Hello"),
AnthropicMessage {
role: "user".to_string(),
content: AnthropicMessageContent::Blocks(vec![]),
},
user_msg("World"),
];
remove_empty_content_messages(&mut messages);
assert_eq!(messages.len(), 2);
}
#[test]
fn test_remove_empty_preserves_non_empty() {
let mut messages = vec![user_msg("Hello"), assistant_msg("Hi"), user_msg("Bye")];
remove_empty_content_messages(&mut messages);
assert_eq!(messages.len(), 3);
}
#[test]
fn test_sanitize_sequence_dedup_tool_results() {
let mut messages = vec![
user_msg("Hi"),
assistant_blocks_msg(vec![tool_use_block("t1", "tool_a")]),
user_blocks_msg(vec![
tool_result_block("t1", "first"),
tool_result_block("t1", "second"),
]),
];
sanitize_message_sequence(&mut messages);
let result_ids = extract_tool_result_ids(&messages[2]);
assert_eq!(result_ids.len(), 1);
assert!(result_ids.contains("t1"));
if let AnthropicMessageContent::Blocks(blocks) = &messages[2].content {
let count = blocks
.iter()
.filter(|b| matches!(b, AnthropicContent::ToolResult { .. }))
.count();
assert_eq!(count, 1, "Only 1 tool_result block after dedup");
}
}
#[test]
fn test_sanitize_sequence_removes_empty_content_preserves_substantive_assistant() {
let mut messages = vec![
user_msg("Hello"),
assistant_msg("Response"),
user_msg(""), ];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[1].role, "assistant");
}
#[test]
fn test_sanitize_sequence_removes_empty_content_and_empty_trailing_assistant() {
let mut messages = vec![
user_msg("Hello"),
assistant_msg(" "), user_msg(""), ];
sanitize_message_sequence(&mut messages);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "user");
}
#[test]
fn test_is_opus_4_7_or_later_matches_canonical_id() {
assert!(is_opus_4_7_or_later("claude-opus-4-7"));
}
#[test]
fn test_is_opus_4_7_or_later_is_case_insensitive() {
assert!(is_opus_4_7_or_later("CLAUDE-OPUS-4-7"));
}
#[test]
fn test_is_opus_4_7_or_later_rejects_opus_4_6() {
assert!(!is_opus_4_7_or_later("claude-opus-4-6"));
}
#[test]
fn test_is_opus_4_7_or_later_rejects_sonnet_4_6() {
assert!(!is_opus_4_7_or_later("claude-sonnet-4-6"));
}
#[test]
fn test_is_opus_4_7_or_later_rejects_empty() {
assert!(!is_opus_4_7_or_later(""));
}
fn request_for(model_id: &str) -> crate::types::GenerateRequest {
crate::types::GenerateRequest::new(
crate::types::Model::custom(model_id, "anthropic"),
vec![crate::types::Message::new(
crate::types::Role::User,
"Hello",
)],
)
}
fn anthropic_config() -> crate::providers::anthropic::types::AnthropicConfig {
crate::providers::anthropic::types::AnthropicConfig::new("key")
}
#[test]
fn test_opus_4_7_strips_temperature_and_top_p() {
let mut req = request_for("claude-opus-4-7");
req.options.temperature = Some(0.0);
req.options.top_p = Some(0.9);
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
assert_eq!(result.request.temperature, None);
assert_eq!(result.request.top_p, None);
assert_eq!(result.request.top_k, None);
}
#[test]
fn test_opus_4_6_preserves_temperature_and_top_p() {
let mut req = request_for("claude-opus-4-6");
req.options.temperature = Some(0.7);
req.options.top_p = Some(0.95);
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
assert_eq!(result.request.temperature, Some(0.7));
assert_eq!(result.request.top_p, Some(0.95));
}
#[test]
fn test_opus_4_7_none_temperature_stays_none() {
let req = request_for("claude-opus-4-7");
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
assert_eq!(result.request.temperature, None);
}
fn anthropic_thinking_options(budget_tokens: u32) -> crate::types::ProviderOptions {
crate::types::ProviderOptions::Anthropic(crate::types::AnthropicOptions {
thinking: Some(crate::types::ThinkingOptions::new(budget_tokens)),
effort: None,
})
}
#[test]
fn test_opus_4_7_thinking_serializes_to_adaptive_only() {
let mut req = request_for("claude-opus-4-7");
req.provider_options = Some(anthropic_thinking_options(32000));
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
let thinking_json = serde_json::to_value(result.request.thinking.unwrap()).unwrap();
assert_eq!(thinking_json, serde_json::json!({"type": "adaptive"}));
}
#[test]
fn test_opus_4_6_preserves_enabled_thinking_budget() {
let mut req = request_for("claude-opus-4-6");
req.provider_options = Some(anthropic_thinking_options(32000));
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
let thinking_json = serde_json::to_value(result.request.thinking.unwrap()).unwrap();
assert_eq!(
thinking_json,
serde_json::json!({"type": "enabled", "budget_tokens": 32000})
);
}
fn has_opus_47_warning(warnings: &[CacheWarning], needle: &str) -> bool {
warnings
.iter()
.any(|w| w.message.contains("Opus 4.7") && w.message.contains(needle))
}
#[test]
fn test_opus_4_7_emits_warning_when_temperature_stripped() {
let mut req = request_for("claude-opus-4-7");
req.options.temperature = Some(0.0);
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
assert!(
has_opus_47_warning(&result.warnings, "temperature"),
"expected Opus-4.7 temperature warning, got {:?}",
result.warnings
);
}
#[test]
fn test_opus_4_7_emits_no_warning_when_nothing_supplied() {
let req = request_for("claude-opus-4-7");
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
assert!(
!result
.warnings
.iter()
.any(|w| w.message.contains("Opus 4.7")),
"expected no Opus-4.7 warnings, got {:?}",
result.warnings
);
}
#[test]
fn test_opus_4_7_emits_warning_when_thinking_rewritten() {
let mut req = request_for("claude-opus-4-7");
req.provider_options = Some(anthropic_thinking_options(32000));
let result = to_anthropic_request(&req, &anthropic_config(), false).unwrap();
assert!(
has_opus_47_warning(&result.warnings, "adaptive"),
"expected Opus-4.7 thinking-rewrite warning, got {:?}",
result.warnings
);
}
}