use aigw_core::error::TranslateError;
use aigw_core::model::{
ChatRequest, ContentPart, Message, MessageContent, Role, ThinkingSource, TypedContentPart,
};
use aigw_core::translate::{RequestTranslator, ThinkingProjector, TranslatedRequest};
use bytes::Bytes;
use http::{HeaderMap, Method};
use crate::types::{
ContentBlock, ImageSource, Message as AnthropicMessage, MessageContent as AnthropicContent,
MessagesRequest, Metadata, Role as AnthropicRole, SystemPrompt, TypedContentBlock,
};
use super::cache_control::{
CacheControlStrategy, DefaultCacheControlStrategy, enforce_breakpoint_cap,
normalize_ttl_ordering,
};
use super::thinking::{AnthropicThinkingProjector, AnthropicThinkingTarget};
use super::tools;
const DEFAULT_MAX_TOKENS: u64 = 4096;
pub struct AnthropicRequestTranslator {
headers: HeaderMap,
url: String,
default_max_tokens: u64,
thinking: Box<dyn ThinkingProjector<AnthropicThinkingTarget>>,
cache_control: Box<dyn CacheControlStrategy>,
}
impl AnthropicRequestTranslator {
pub fn new(transport: &crate::Transport, default_max_tokens: Option<u64>) -> Self {
Self {
headers: transport.headers().clone(),
url: transport.url("/v1/messages"),
default_max_tokens: default_max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
thinking: Box::new(AnthropicThinkingProjector::default()),
cache_control: Box::new(DefaultCacheControlStrategy::default()),
}
}
#[must_use]
pub fn with_thinking_projector(
mut self,
projector: Box<dyn ThinkingProjector<AnthropicThinkingTarget>>,
) -> Self {
self.thinking = projector;
self
}
#[must_use]
pub fn with_cache_control_strategy(mut self, strategy: Box<dyn CacheControlStrategy>) -> Self {
self.cache_control = strategy;
self
}
}
impl RequestTranslator for AnthropicRequestTranslator {
fn translate_request(&self, req: &ChatRequest) -> Result<TranslatedRequest, TranslateError> {
if let Some(n) = req.n
&& n > 1
{
return Err(TranslateError::UnsupportedFeature {
provider: "anthropic",
feature: "n > 1".into(),
});
}
let system = extract_system(&req.messages);
let messages = translate_messages(&req.messages)?;
let mut extra = serde_json::Map::new();
for (k, v) in &req.extra {
extra.insert(k.clone(), v.clone());
}
let mut target = AnthropicThinkingTarget {
max_tokens: req.max_tokens.unwrap_or(self.default_max_tokens),
..Default::default()
};
if req.thinking.is_some() {
self.thinking
.apply(&req.model, req.thinking.as_ref(), &mut target);
extra.remove("thinking");
} else if let Some(legacy) = extra.remove("thinking") {
target.thinking = serde_json::from_value(legacy).ok();
}
if let Some(effort) = target.output_config_effort {
extra.insert(
"output_config".into(),
serde_json::json!({ "effort": effort }),
);
} else if target.clear_output_config {
extra.remove("output_config");
}
let mut native = MessagesRequest::builder()
.model(&req.model)
.messages(messages)
.max_tokens(target.max_tokens)
.maybe_system(system)
.maybe_temperature(req.temperature)
.maybe_top_p(req.top_p)
.maybe_stop_sequences(req.stop.as_ref().map(|s| s.to_vec()))
.maybe_stream(req.stream)
.maybe_tools(req.tools.as_ref().map(|t| tools::translate_tools(t)))
.maybe_tool_choice(req.tool_choice.as_ref().map(tools::translate_tool_choice))
.maybe_metadata(req.user.as_ref().map(|u| Metadata {
user_id: Some(u.clone()),
}))
.maybe_thinking(target.thinking)
.extra(extra)
.build();
self.cache_control.apply(&mut native);
enforce_breakpoint_cap(&mut native);
normalize_ttl_ordering(&mut native);
let body = serde_json::to_vec(&native)?;
Ok(TranslatedRequest {
url: self.url.clone(),
method: Method::POST,
headers: self.headers.clone(),
body: Bytes::from(body),
})
}
}
fn extract_system(messages: &[Message]) -> Option<SystemPrompt> {
let mut all_texts = Vec::new();
for msg in messages {
if !matches!(msg.role, Role::System | Role::Developer) {
continue;
}
match &msg.content {
Some(MessageContent::Text(s)) => all_texts.push(s.clone()),
Some(MessageContent::Parts(parts)) => {
let text: String = parts
.iter()
.filter_map(|p| match p {
ContentPart::Known(TypedContentPart::Text { text, .. }) => {
Some(text.as_str())
}
_ => None,
})
.collect::<Vec<_>>()
.join("");
if !text.is_empty() {
all_texts.push(text);
}
}
None => {}
}
}
if all_texts.is_empty() {
None
} else {
Some(SystemPrompt::Text(all_texts.join("\n\n")))
}
}
fn translate_messages(messages: &[Message]) -> Result<Vec<AnthropicMessage>, TranslateError> {
let non_system: Vec<&Message> = messages
.iter()
.filter(|m| !matches!(m.role, Role::System | Role::Developer))
.collect();
let mut result = Vec::new();
let mut i = 0;
while i < non_system.len() {
let msg = non_system[i];
match msg.role {
Role::User => {
result.push(translate_user_message(msg)?);
i += 1;
}
Role::Assistant => {
result.push(translate_assistant_message(msg)?);
i += 1;
}
Role::Tool => {
let mut tool_blocks = Vec::new();
while i < non_system.len() && non_system[i].role == Role::Tool {
tool_blocks.push(translate_tool_result(non_system[i])?);
i += 1;
}
result.push(AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicContent::Blocks(tool_blocks),
});
}
_ => {
result.push(translate_user_message(msg)?);
i += 1;
}
}
}
Ok(result)
}
fn translate_user_message(msg: &Message) -> Result<AnthropicMessage, TranslateError> {
let content = match &msg.content {
Some(MessageContent::Text(s)) => AnthropicContent::Text(s.clone()),
Some(MessageContent::Parts(parts)) => {
let mut blocks = Vec::with_capacity(parts.len());
for part in parts {
if let Some(block) = translate_content_part(part)? {
blocks.push(block);
}
}
AnthropicContent::Blocks(blocks)
}
None => AnthropicContent::Text(String::new()),
};
Ok(AnthropicMessage {
role: AnthropicRole::User,
content,
})
}
fn translate_assistant_message(msg: &Message) -> Result<AnthropicMessage, TranslateError> {
let mut blocks = Vec::new();
match &msg.content {
Some(MessageContent::Text(s)) if !s.is_empty() => {
blocks.push(ContentBlock::Typed(TypedContentBlock::Text {
text: s.clone(),
cache_control: None,
}));
}
Some(MessageContent::Parts(parts)) => {
for part in parts {
if let Some(block) = translate_content_part(part)? {
blocks.push(block);
}
}
}
_ => {}
}
if let Some(tool_calls) = &msg.tool_calls {
for tc in tool_calls {
let input: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::json!({}));
blocks.push(ContentBlock::Typed(TypedContentBlock::ToolUse {
id: tc.id.clone(),
name: tc.function.name.clone(),
input,
cache_control: None,
}));
}
}
let content = if blocks.is_empty() {
AnthropicContent::Text(String::new())
} else {
AnthropicContent::Blocks(blocks)
};
Ok(AnthropicMessage {
role: AnthropicRole::Assistant,
content,
})
}
fn translate_tool_result(msg: &Message) -> Result<ContentBlock, TranslateError> {
let tool_use_id = msg
.tool_call_id
.clone()
.ok_or(TranslateError::MissingField {
field: "tool_call_id",
})?;
let content = msg.content.as_ref().map(|c| match c {
MessageContent::Text(s) => crate::types::ToolResultContent::Text(s.clone()),
MessageContent::Parts(_) => {
crate::types::ToolResultContent::Text(serde_json::to_string(c).unwrap_or_default())
}
});
Ok(ContentBlock::Typed(TypedContentBlock::ToolResult {
tool_use_id,
content,
is_error: None,
cache_control: None,
}))
}
fn translate_content_part(part: &ContentPart) -> Result<Option<ContentBlock>, TranslateError> {
match part {
ContentPart::Known(TypedContentPart::Text { text, .. }) => {
Ok(Some(ContentBlock::Typed(TypedContentBlock::Text {
text: text.clone(),
cache_control: None,
})))
}
ContentPart::Known(TypedContentPart::ImageUrl { image_url, .. }) => {
let source = translate_image_source(&image_url.url)?;
Ok(Some(ContentBlock::Typed(TypedContentBlock::Image {
source,
cache_control: None,
})))
}
ContentPart::Known(TypedContentPart::Thinking {
thinking,
signature,
source,
..
}) => Ok(forward_thinking_to_anthropic(*source).then(|| {
ContentBlock::Typed(TypedContentBlock::Thinking {
thinking: thinking.clone(),
signature: signature.clone(),
})
})),
ContentPart::Known(TypedContentPart::RedactedThinking { data, source, .. }) => {
Ok(forward_thinking_to_anthropic(*source).then(|| {
ContentBlock::Typed(TypedContentBlock::RedactedThinking { data: data.clone() })
}))
}
ContentPart::Raw(obj) => {
Ok(Some(ContentBlock::Raw(obj.clone())))
}
_ => Err(TranslateError::IncompatibleContent {
reason: "unsupported content part type for Anthropic".into(),
}),
}
}
const fn forward_thinking_to_anthropic(source: Option<ThinkingSource>) -> bool {
matches!(source, None | Some(ThinkingSource::Anthropic))
}
fn translate_image_source(url: &str) -> Result<ImageSource, TranslateError> {
if let Some(rest) = url.strip_prefix("data:") {
let (header, data) =
rest.split_once(',')
.ok_or_else(|| TranslateError::IncompatibleContent {
reason: "malformed data: URI".into(),
})?;
let media_type =
header
.strip_suffix(";base64")
.ok_or_else(|| TranslateError::IncompatibleContent {
reason: "data: URI must be base64-encoded".into(),
})?;
Ok(ImageSource::Base64 {
media_type: media_type.to_owned(),
data: data.to_owned(),
})
} else {
Ok(ImageSource::Url {
url: url.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use aigw_core::model::{FunctionCall, ImageUrl, ToolCall};
fn user_msg(text: &str) -> Message {
Message {
role: Role::User,
content: Some(MessageContent::Text(text.into())),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
}
}
fn system_msg(text: &str) -> Message {
Message {
role: Role::System,
content: Some(MessageContent::Text(text.into())),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
}
}
fn tool_msg(tool_call_id: &str, content: &str) -> Message {
Message {
role: Role::Tool,
content: Some(MessageContent::Text(content.into())),
name: None,
tool_call_id: Some(tool_call_id.into()),
tool_calls: None,
extra: Default::default(),
}
}
#[test]
fn extract_no_system() {
let msgs = vec![user_msg("hi")];
assert!(extract_system(&msgs).is_none());
}
#[test]
fn extract_single_system() {
let msgs = vec![system_msg("You are helpful"), user_msg("hi")];
match extract_system(&msgs) {
Some(SystemPrompt::Text(s)) => assert_eq!(s, "You are helpful"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn extract_multiple_system_messages() {
let msgs = vec![
system_msg("You are helpful"),
system_msg("Be concise"),
user_msg("hi"),
];
match extract_system(&msgs) {
Some(SystemPrompt::Text(s)) => assert_eq!(s, "You are helpful\n\nBe concise"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn translate_messages_filters_system() {
let msgs = vec![system_msg("system"), user_msg("hello")];
let result = translate_messages(&msgs).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].role, AnthropicRole::User);
}
#[test]
fn consecutive_tool_messages_merged() {
let msgs = vec![
user_msg("check weather"),
tool_msg("call_1", "72F sunny"),
tool_msg("call_2", "65F cloudy"),
];
let result = translate_messages(&msgs).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[1].role, AnthropicRole::User);
match &result[1].content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2);
for block in blocks {
assert!(matches!(
block,
ContentBlock::Typed(TypedContentBlock::ToolResult { .. })
));
}
}
_ => panic!("expected Blocks"),
}
}
#[test]
fn assistant_message_with_tool_calls() {
let msg = Message {
role: Role::Assistant,
content: Some(MessageContent::Text("Let me check.".into())),
name: None,
tool_call_id: None,
tool_calls: Some(vec![ToolCall {
id: "call_1".into(),
kind: "function".into(),
function: FunctionCall {
name: "get_weather".into(),
arguments: r#"{"location":"SF"}"#.into(),
extra: Default::default(),
},
extra: Default::default(),
}]),
extra: Default::default(),
};
let result = translate_assistant_message(&msg).unwrap();
assert_eq!(result.role, AnthropicRole::Assistant);
match &result.content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2); assert!(matches!(
&blocks[0],
ContentBlock::Typed(TypedContentBlock::Text { text, .. }) if text == "Let me check."
));
assert!(matches!(
&blocks[1],
ContentBlock::Typed(TypedContentBlock::ToolUse { name, .. }) if name == "get_weather"
));
}
_ => panic!("expected Blocks"),
}
}
#[test]
fn image_url_translation() {
let source = translate_image_source("https://example.com/img.png").unwrap();
assert!(matches!(source, ImageSource::Url { url } if url == "https://example.com/img.png"));
}
#[test]
fn image_data_uri_translation() {
let source = translate_image_source("data:image/png;base64,iVBOR...").unwrap();
match source {
ImageSource::Base64 { media_type, data } => {
assert_eq!(media_type, "image/png");
assert_eq!(data, "iVBOR...");
}
_ => panic!("expected Base64"),
}
}
#[test]
fn image_content_in_user_message() {
let msg = Message {
role: Role::User,
content: Some(MessageContent::Parts(vec![
ContentPart::Known(TypedContentPart::Text {
text: "What's in this image?".into(),
extra: Default::default(),
}),
ContentPart::Known(TypedContentPart::ImageUrl {
image_url: ImageUrl {
url: "https://example.com/cat.jpg".into(),
detail: None,
extra: Default::default(),
},
extra: Default::default(),
}),
])),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
};
let result = translate_user_message(&msg).unwrap();
match &result.content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2);
assert!(matches!(
&blocks[0],
ContentBlock::Typed(TypedContentBlock::Text { .. })
));
assert!(matches!(
&blocks[1],
ContentBlock::Typed(TypedContentBlock::Image { .. })
));
}
_ => panic!("expected Blocks"),
}
}
#[test]
fn anthropic_thinking_part_round_trips_to_native() {
let msg = Message {
role: Role::Assistant,
content: Some(MessageContent::Parts(vec![
ContentPart::Known(TypedContentPart::Thinking {
thinking: "Let me think...".into(),
signature: "ErWj123".into(),
source: Some(ThinkingSource::Anthropic),
extra: Default::default(),
}),
ContentPart::Known(TypedContentPart::Text {
text: "Answer is 42.".into(),
extra: Default::default(),
}),
])),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
};
let result = translate_assistant_message(&msg).unwrap();
match &result.content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2);
assert!(matches!(
&blocks[0],
ContentBlock::Typed(TypedContentBlock::Thinking { signature, .. })
if signature == "ErWj123"
));
assert!(matches!(
&blocks[1],
ContentBlock::Typed(TypedContentBlock::Text { .. })
));
}
_ => panic!("expected Blocks"),
}
}
#[test]
fn gemini_sourced_thinking_part_is_dropped() {
let msg = Message {
role: Role::Assistant,
content: Some(MessageContent::Parts(vec![
ContentPart::Known(TypedContentPart::Thinking {
thinking: "from gemini".into(),
signature: "garbage".into(),
source: Some(ThinkingSource::Gemini),
extra: Default::default(),
}),
ContentPart::Known(TypedContentPart::Text {
text: "kept".into(),
extra: Default::default(),
}),
])),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
};
let result = translate_assistant_message(&msg).unwrap();
match &result.content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1, "gemini-sourced thinking must be dropped");
assert!(matches!(
&blocks[0],
ContentBlock::Typed(TypedContentBlock::Text { text, .. }) if text == "kept"
));
}
_ => panic!("expected Blocks"),
}
}
#[test]
fn untagged_thinking_part_is_forwarded() {
let msg = Message {
role: Role::Assistant,
content: Some(MessageContent::Parts(vec![ContentPart::Known(
TypedContentPart::Thinking {
thinking: "no source".into(),
signature: "Ezzz".into(),
source: None,
extra: Default::default(),
},
)])),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
};
let result = translate_assistant_message(&msg).unwrap();
match &result.content {
AnthropicContent::Blocks(blocks) => assert_eq!(blocks.len(), 1),
_ => panic!("expected Blocks"),
}
}
#[test]
fn redacted_thinking_anthropic_source_forwarded() {
let msg = Message {
role: Role::Assistant,
content: Some(MessageContent::Parts(vec![ContentPart::Known(
TypedContentPart::RedactedThinking {
data: "blob".into(),
source: Some(ThinkingSource::Anthropic),
extra: Default::default(),
},
)])),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
};
let result = translate_assistant_message(&msg).unwrap();
match &result.content {
AnthropicContent::Blocks(blocks) => {
assert!(matches!(
&blocks[0],
ContentBlock::Typed(TypedContentBlock::RedactedThinking { data })
if data == "blob"
));
}
_ => panic!("expected Blocks"),
}
}
#[test]
fn canonical_thinking_field_invokes_projector() {
use aigw_core::model::{ThinkingLevel, ThinkingRequest};
let transport = crate::Transport::new(crate::TransportConfig {
api_key: secrecy::SecretString::from("sk-ant-test"),
..Default::default()
})
.unwrap();
let translator = AnthropicRequestTranslator::new(&transport, None);
let req = ChatRequest::builder()
.model("claude-opus-4-6")
.messages(vec![user_msg("hi")])
.thinking(ThinkingRequest::Level {
level: ThinkingLevel::High,
})
.build();
let translated = translator.translate_request(&req).unwrap();
let body: serde_json::Value = serde_json::from_slice(&translated.body).unwrap();
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["output_config"]["effort"], "high");
}
#[test]
fn legacy_extra_thinking_still_works_when_canonical_absent() {
let transport = crate::Transport::new(crate::TransportConfig {
api_key: secrecy::SecretString::from("sk-ant-test"),
..Default::default()
})
.unwrap();
let translator = AnthropicRequestTranslator::new(&transport, None);
let mut extra = serde_json::Map::new();
extra.insert(
"thinking".into(),
serde_json::json!({"type":"enabled","budget_tokens":5000}),
);
let req = ChatRequest::builder()
.model("claude-opus-4-5")
.messages(vec![user_msg("hi")])
.extra(extra)
.build();
let translated = translator.translate_request(&req).unwrap();
let body: serde_json::Value = serde_json::from_slice(&translated.body).unwrap();
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], 5000);
}
#[test]
fn canonical_thinking_overrides_extra_thinking() {
use aigw_core::model::ThinkingRequest;
let transport = crate::Transport::new(crate::TransportConfig {
api_key: secrecy::SecretString::from("sk-ant-test"),
..Default::default()
})
.unwrap();
let translator = AnthropicRequestTranslator::new(&transport, None);
let mut extra = serde_json::Map::new();
extra.insert(
"thinking".into(),
serde_json::json!({"type":"enabled","budget_tokens":99999}),
);
let req = ChatRequest::builder()
.model("claude-opus-4-5")
.messages(vec![user_msg("hi")])
.thinking(ThinkingRequest::Budget {
budget_tokens: 8_000,
})
.extra(extra)
.build();
let translated = translator.translate_request(&req).unwrap();
let body: serde_json::Value = serde_json::from_slice(&translated.body).unwrap();
assert_eq!(body["thinking"]["budget_tokens"], 8_000);
}
#[test]
fn translate_injects_cache_control_on_default() {
let transport = crate::Transport::new(crate::TransportConfig {
api_key: secrecy::SecretString::from("sk-ant-test"),
..Default::default()
})
.unwrap();
let translator = AnthropicRequestTranslator::new(&transport, None);
let req = ChatRequest::builder()
.model("claude-opus-4-5")
.messages(vec![
user_msg("first"),
Message {
role: Role::Assistant,
content: Some(MessageContent::Text("ok".into())),
name: None,
tool_call_id: None,
tool_calls: None,
extra: Default::default(),
},
user_msg("second"),
])
.build();
let translated = translator.translate_request(&req).unwrap();
let body: serde_json::Value = serde_json::from_slice(&translated.body).unwrap();
let cc = &body["messages"][0]["content"][0]["cache_control"];
assert_eq!(cc["type"], "ephemeral");
}
#[test]
fn translate_with_no_cache_control_strategy() {
use super::super::cache_control::NoCacheControlStrategy;
let transport = crate::Transport::new(crate::TransportConfig {
api_key: secrecy::SecretString::from("sk-ant-test"),
..Default::default()
})
.unwrap();
let translator = AnthropicRequestTranslator::new(&transport, None)
.with_cache_control_strategy(Box::new(NoCacheControlStrategy));
let req = ChatRequest::builder()
.model("claude-opus-4-5")
.messages(vec![user_msg("first"), user_msg("second")])
.build();
let translated = translator.translate_request(&req).unwrap();
let body: serde_json::Value = serde_json::from_slice(&translated.body).unwrap();
let messages = body["messages"].as_array().unwrap();
for msg in messages {
if let Some(content) = msg["content"].as_array() {
for block in content {
assert!(block.get("cache_control").is_none());
}
}
}
}
#[test]
fn tool_result_missing_tool_call_id() {
let msg = Message {
role: Role::Tool,
content: Some(MessageContent::Text("result".into())),
name: None,
tool_call_id: None, tool_calls: None,
extra: Default::default(),
};
let err = translate_tool_result(&msg).unwrap_err();
assert!(matches!(
err,
TranslateError::MissingField {
field: "tool_call_id"
}
));
}
}