use bytes::Bytes;
use http::HeaderMap;
use lellm_core::{
CacheControl, ChatRequest, ChatResponse, ContentBlock, LlmError, Message, ReasoningConfig,
TextBlock, ThinkingBlock, TokenUsage, ToolCall, ToolChoice,
};
use std::borrow::Cow;
use super::codec::{
AuthStyle, Capabilities, ChatCodec, CodecRequest, ModelCapabilities, ProviderMeta, StreamChunk,
StreamParseResult, ToolCallDelta,
};
use super::stream::sse_frame::SseFrame;
#[derive(Debug, Clone)]
pub struct AnthropicCodec;
impl ProviderMeta for AnthropicCodec {
fn provider_id(&self) -> &'static str {
"anthropic"
}
fn default_base_url(&self) -> &'static str {
"https://api.anthropic.com"
}
fn auth_style(&self) -> AuthStyle {
AuthStyle::CustomHeader("x-api-key")
}
}
impl ChatCodec for AnthropicCodec {
fn encode(&self, req: &ChatRequest, stream: bool) -> Result<CodecRequest, LlmError> {
let mut system_blocks: Vec<ContentBlock> = Vec::new();
let mut messages: Vec<serde_json::Map<String, serde_json::Value>> = Vec::new();
for m in &req.messages {
match m {
Message::System { content } => {
system_blocks = content.clone();
}
Message::User { content } => {
let mut map = serde_json::Map::new();
map.insert("role".into(), "user".into());
map.insert(
"content".into(),
serialize_anthropic_content_blocks(content)?,
);
messages.push(map);
}
Message::Assistant { content } => {
let mut map = serde_json::Map::new();
map.insert("role".into(), "assistant".into());
map.insert(
"content".into(),
serialize_anthropic_content_blocks(content)?,
);
messages.push(map);
}
Message::ToolResult {
tool_call_id,
is_error,
content,
} => {
let mut map = serde_json::Map::new();
map.insert("role".into(), "user".into());
let mut block = serde_json::Map::new();
block.insert("type".into(), "tool_result".into());
block.insert("tool_use_id".into(), tool_call_id.clone().into());
block.insert("is_error".into(), (*is_error).into());
block.insert(
"content".into(),
serialize_anthropic_content_blocks(content)?,
);
map.insert(
"content".into(),
serde_json::Value::Array(vec![serde_json::Value::Object(block)]),
);
messages.push(map);
}
}
}
let mut body = serde_json::Map::new();
body.insert("model".into(), req.model.clone().into());
if !system_blocks.is_empty() {
let has_cache = system_blocks
.iter()
.any(|b| matches!(b, ContentBlock::Text(tb) if tb.cache_control.is_some()));
if has_cache {
let blocks = serialize_anthropic_content_blocks(&system_blocks)?;
body.insert("system".into(), blocks);
} else {
let text: String = system_blocks.iter().filter_map(|b| b.as_text()).collect();
body.insert("system".into(), text.into());
}
}
body.insert(
"messages".into(),
serde_json::to_value(messages).map_err(|e| LlmError::Parse {
detail: format!("Failed to serialize messages: {}", e),
})?,
);
let max_tokens = req.max_tokens.ok_or_else(|| LlmError::InvalidRequest {
message: "Anthropic requires max_tokens".into(),
})?;
body.insert("max_tokens".into(), (max_tokens as u64).into());
if let Some(ref reasoning) = req.reasoning {
match reasoning {
ReasoningConfig::Disabled => {} ReasoningConfig::Low | ReasoningConfig::Medium | ReasoningConfig::High => {
let default_budget = match reasoning {
ReasoningConfig::Low => 2048,
ReasoningConfig::Medium => 8192,
ReasoningConfig::High => 32768,
_ => unreachable!(),
};
let budget_tokens = req.max_reasoning_tokens.unwrap_or(default_budget) as u64;
body.insert(
"thinking".into(),
serde_json::json!({
"type": "enabled",
"budget_tokens": budget_tokens
}),
);
}
}
}
if stream {
body.insert("stream".into(), true.into());
}
if let Some(temp) = req.temperature {
body.insert("temperature".into(), temp.into());
}
if let Some(top_p) = req.top_p {
body.insert("top_p".into(), top_p.into());
}
if let Some(ref tool_choice) = req.tool_choice {
body.insert(
"tool_choice".into(),
serialize_anthropic_tool_choice(tool_choice),
);
}
if let Some(ref stop_sequences) = req.stop_sequences {
body.insert(
"stop_sequences".into(),
serde_json::to_value(stop_sequences).unwrap(),
);
}
if let Some(ref tools) = req.tools {
let anthropic_tools: Vec<serde_json::Value> = tools
.iter()
.map(|t| {
let mut obj = serde_json::Map::new();
obj.insert("name".into(), t.name.clone().into());
obj.insert("description".into(), t.description.clone().into());
obj.insert("input_schema".into(), t.parameters.clone());
if t.cache_control == Some(CacheControl::Breakpoint) {
obj.insert(
"cache_control".into(),
serde_json::json!({"type": "ephemeral"}),
);
}
serde_json::Value::Object(obj)
})
.collect();
body.insert("tools".into(), serde_json::Value::Array(anthropic_tools));
}
if let Some(ref extra) = req.extra {
for (k, v) in extra {
body.insert(k.clone(), v.clone());
}
}
let body_bytes = serde_json::to_vec(&body).map_err(|e| LlmError::Parse {
detail: format!("Failed to serialize request body: {}", e),
})?;
let mut headers = HeaderMap::new();
headers.insert(
"anthropic-version",
"2023-06-01".parse().map_err(|_| LlmError::Parse {
detail: "Invalid header value".into(),
})?,
);
Ok(CodecRequest {
path: Cow::Borrowed("/v1/messages"),
headers,
body: Bytes::from(body_bytes),
})
}
fn decode(&self, body: &[u8]) -> Result<ChatResponse, LlmError> {
let raw: serde_json::Value = serde_json::from_slice(body).map_err(|e| LlmError::Parse {
detail: format!("Invalid JSON: {}", e),
})?;
let content_val = raw
.get("content")
.and_then(|c| c.as_array())
.ok_or(LlmError::Parse {
detail: "Missing content array".into(),
})?;
let mut content: Vec<ContentBlock> = Vec::new();
for block in content_val {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str())
&& !text.is_empty()
{
content.push(ContentBlock::Text(TextBlock {
text: text.into(),
cache_control: None,
}));
}
}
"tool_use" => {
let id = block
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = block
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let input = block
.get("input")
.unwrap_or(&serde_json::Value::Object(Default::default()))
.clone();
content.push(ContentBlock::ToolCall(ToolCall {
id,
name,
arguments: input,
}));
}
"thinking" => {
let thinking = block
.get("thinking")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let redacted = block
.get("redacted_thinking")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if !thinking.is_empty() || redacted.is_some() {
content.push(ContentBlock::Thinking(ThinkingBlock { thinking, redacted }));
}
}
_ => {}
}
}
let usage_val = raw.get("usage");
let prompt_tokens = usage_val
.and_then(|u| u.get("input_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let completion_tokens = usage_val
.and_then(|u| u.get("output_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let usage = TokenUsage {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
};
Ok(ChatResponse::new(content, usage, raw))
}
fn decode_sse(&self, frame: &SseFrame) -> Result<StreamParseResult, LlmError> {
let data = &frame.data;
if data.is_empty() {
return Ok(StreamParseResult::empty());
}
let val: serde_json::Value = serde_json::from_str(data).map_err(|e| LlmError::Parse {
detail: format!("Invalid SSE JSON: {}", e),
})?;
let event_type = val.get("type").and_then(|t| t.as_str()).unwrap_or("");
match event_type {
"message_start" => {
if let Some(usage_val) = val.get("usage") {
let input_tokens = usage_val
.get("input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
if input_tokens > 0 {
return Ok(StreamParseResult::chunk(StreamChunk::InputTokens(
input_tokens,
)));
}
}
return Ok(StreamParseResult::empty());
}
"content_block_start" => {
let block = val.get("content_block").unwrap_or(&serde_json::Value::Null);
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
if block_type == "tool_use" {
let index = val.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let id = block.get("id").and_then(|v| v.as_str()).map(|s| s.into());
let name = block.get("name").and_then(|v| v.as_str()).map(|s| s.into());
return Ok(StreamParseResult::chunk(StreamChunk::ToolCallDelta(
ToolCallDelta {
index,
id,
name,
arguments_delta: None,
},
)));
}
}
"content_block_delta" => {
let delta = val.get("delta").unwrap_or(&serde_json::Value::Null);
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
let index = val.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
if delta_type == "text_delta" {
if let Some(text) = delta.get("text").and_then(|t| t.as_str())
&& !text.is_empty()
{
return Ok(StreamParseResult::chunk(StreamChunk::TextDelta(
text.into(),
)));
}
} else if delta_type == "input_json_delta" {
let partial = delta
.get("partial_json")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if partial.is_some() {
return Ok(StreamParseResult::chunk(StreamChunk::ToolCallDelta(
ToolCallDelta {
index,
id: None,
name: None,
arguments_delta: partial,
},
)));
}
} else if delta_type == "thinking_delta" {
let thinking = delta
.get("thinking")
.and_then(|t| t.as_str())
.map(|s| s.to_string());
let redacted = delta
.get("redacted_thinking")
.and_then(|t| t.as_str())
.map(|s| s.to_string());
if let Some(t) = thinking {
return Ok(StreamParseResult::chunk(StreamChunk::ThinkingDelta {
thinking: t,
redacted,
}));
}
if let Some(r) = redacted {
return Ok(StreamParseResult::chunk(StreamChunk::ThinkingDelta {
thinking: String::new(),
redacted: Some(r),
}));
}
}
}
"message_delta" => {
let mut chunks = Vec::new();
if let Some(usage_val) = val.get("usage") {
let output_tokens = usage_val
.get("output_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
if output_tokens > 0 {
chunks.push(StreamChunk::OutputTokens(output_tokens));
}
}
chunks.push(StreamChunk::Done);
return Ok(StreamParseResult { chunks });
}
_ => {}
}
Ok(StreamParseResult::empty())
}
}
impl ModelCapabilities for AnthropicCodec {
fn capabilities_for(&self, model: &str) -> Capabilities {
let mut caps = Capabilities::default();
let lower = model.to_lowercase();
if lower.contains("claude-3") || lower.contains("claude-4") {
caps.supports_image_input = true;
}
if lower.contains("sonnet")
|| lower.contains("opus")
|| (lower.contains("claude-4") && lower.contains("sonnet"))
{
caps.supports_reasoning = true;
caps.supports_stream_thinking = true;
}
if lower.contains("claude-3") || lower.contains("claude-4") {
caps.supports_tool_call = true;
}
caps
}
}
fn serialize_anthropic_content_blocks(
blocks: &[ContentBlock],
) -> Result<serde_json::Value, LlmError> {
let arr: Vec<serde_json::Value> = blocks
.iter()
.map(|block| match block {
ContentBlock::Text(tb) => {
let mut obj = serde_json::Map::new();
obj.insert("type".into(), "text".into());
obj.insert("text".into(), serde_json::json!(tb.text));
if tb.cache_control == Some(CacheControl::Breakpoint) {
obj.insert(
"cache_control".into(),
serde_json::json!({"type": "ephemeral"}),
);
}
Ok(serde_json::Value::Object(obj))
}
ContentBlock::Thinking(tb) => {
let mut obj = serde_json::Map::new();
obj.insert("type".into(), "thinking".into());
obj.insert("thinking".into(), serde_json::json!(tb.thinking));
if let Some(ref redacted) = tb.redacted {
obj.insert("redacted_thinking".into(), serde_json::json!(redacted));
}
Ok(serde_json::Value::Object(obj))
}
ContentBlock::ToolCall(tc) => Ok(serde_json::json!({
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": tc.arguments
})),
ContentBlock::Image { source: _ } => Err(LlmError::UnsupportedFeature {
feature: "Image in content blocks (Anthropic adapter)".into(),
}),
})
.collect::<Result<Vec<_>, _>>()?;
Ok(serde_json::Value::Array(arr))
}
fn serialize_anthropic_tool_choice(choice: &ToolChoice) -> serde_json::Value {
match choice {
ToolChoice::Tool { name } => {
serde_json::json!({"type": "tool", "name": name})
}
ToolChoice::Any => "any".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use lellm_core::{CacheControl, ChatRequest, TextBlock};
#[test]
fn test_text_block_with_cache_control() {
let blocks = vec![ContentBlock::Text(TextBlock {
text: "system prompt".into(),
cache_control: Some(CacheControl::Breakpoint),
})];
let result = serialize_anthropic_content_blocks(&blocks).unwrap();
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[0]["text"], "system prompt");
assert_eq!(arr[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn test_text_block_without_cache_control() {
let blocks = vec![ContentBlock::Text(TextBlock {
text: "hello".into(),
cache_control: None,
})];
let result = serialize_anthropic_content_blocks(&blocks).unwrap();
let arr = result.as_array().unwrap();
assert_eq!(arr[0]["type"], "text");
assert!(arr[0].get("cache_control").is_none());
}
#[test]
fn test_system_message_with_cache_uses_array_format() {
let codec = AnthropicCodec;
let req = ChatRequest {
model: "claude-3".into(),
messages: vec![Message::System {
content: vec![ContentBlock::Text(TextBlock {
text: "system prompt".into(),
cache_control: Some(CacheControl::Breakpoint),
})],
}],
max_tokens: Some(1024),
..Default::default()
};
let encoded = codec.encode(&req, false).unwrap();
let body: serde_json::Value = serde_json::from_slice(&encoded.body).unwrap();
assert!(body["system"].is_array());
let system_arr = body["system"].as_array().unwrap();
assert_eq!(system_arr[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn test_system_message_without_cache_uses_string_format() {
let codec = AnthropicCodec;
let req = ChatRequest {
model: "claude-3".into(),
messages: vec![Message::System {
content: vec![ContentBlock::text("system prompt".into())],
}],
max_tokens: Some(1024),
..Default::default()
};
let encoded = codec.encode(&req, false).unwrap();
let body: serde_json::Value = serde_json::from_slice(&encoded.body).unwrap();
assert!(body["system"].is_string());
assert_eq!(body["system"], "system prompt");
}
#[test]
fn test_tool_with_cache_control() {
let codec = AnthropicCodec;
let req = ChatRequest {
model: "claude-3".into(),
messages: vec![],
max_tokens: Some(1024),
tools: Some(vec![lellm_core::ToolDefinition {
name: "search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({"type": "object"}),
cache_control: Some(CacheControl::Breakpoint),
}]),
..Default::default()
};
let encoded = codec.encode(&req, false).unwrap();
let body: serde_json::Value = serde_json::from_slice(&encoded.body).unwrap();
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools[0]["name"], "search");
assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn test_tool_without_cache_control() {
let codec = AnthropicCodec;
let req = ChatRequest {
model: "claude-3".into(),
messages: vec![],
max_tokens: Some(1024),
tools: Some(vec![lellm_core::ToolDefinition {
name: "search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({"type": "object"}),
cache_control: None,
}]),
..Default::default()
};
let encoded = codec.encode(&req, false).unwrap();
let body: serde_json::Value = serde_json::from_slice(&encoded.body).unwrap();
let tools = body["tools"].as_array().unwrap();
assert!(tools[0].get("cache_control").is_none());
}
}