use super::error::ConversionError;
use crate::attachment;
use adk_anthropic::ImageMediaType;
use adk_anthropic::{
Base64ImageSource, Base64PdfSource, CacheControlEphemeral, ContentBlock, ContextManagement,
DocumentBlock, ImageBlock, Message, MessageCreateParams, MessageParam, MessageRole, Model,
PlainTextSource, StopReason, SystemPrompt, TextBlock, ToolParam, ToolResultBlock,
ToolResultBlockContent, ToolUnionParam, ToolUseBlock, UrlImageSource, UrlPdfSource,
};
use adk_core::{Content, FinishReason, LlmResponse, Part, UsageMetadata};
use serde_json::Value;
use std::collections::HashMap;
fn tool_result_content(value: &Value) -> ToolResultBlockContent {
match value {
Value::String(text) => ToolResultBlockContent::String(text.clone()),
Value::Object(_) | Value::Array(_) => {
ToolResultBlockContent::String(serde_json::to_string(value).unwrap_or_default())
}
other => ToolResultBlockContent::String(other.to_string()),
}
}
pub fn content_to_message(
content: &Content,
_prompt_caching: bool,
) -> Result<MessageParam, ConversionError> {
let role = match content.role.as_str() {
"user" | "function" | "tool" => MessageRole::User,
"model" | "assistant" => MessageRole::Assistant,
_ => MessageRole::User,
};
let blocks: Vec<ContentBlock> = content
.parts
.iter()
.filter_map(|part| match part {
Part::Text { text } => {
if text.is_empty() {
None
} else {
Some(ContentBlock::Text(TextBlock::new(text.clone())))
}
}
Part::FunctionCall { name, args, id, .. } => {
let block = ToolUseBlock {
id: id.clone().unwrap_or_else(|| format!("call_{name}")),
name: name.clone(),
input: args.clone(),
cache_control: None,
};
Some(ContentBlock::ToolUse(block))
}
Part::FunctionResponse { function_response, id } => {
Some(ContentBlock::ToolResult(ToolResultBlock {
tool_use_id: id.clone().unwrap_or_else(|| "unknown".to_string()),
content: Some(tool_result_content(&function_response.response)),
is_error: None,
cache_control: None,
}))
}
Part::InlineData { mime_type, data } => {
let media_type = match mime_type.as_str() {
"image/jpeg" => Some(ImageMediaType::Jpeg),
"image/png" => Some(ImageMediaType::Png),
"image/gif" => Some(ImageMediaType::Gif),
"image/webp" => Some(ImageMediaType::Webp),
_ => None,
};
if let Some(media_type) = media_type {
let encoded = attachment::encode_base64(data);
Some(ContentBlock::Image(ImageBlock::new_with_base64(Base64ImageSource::new(
encoded, media_type,
))))
} else if mime_type == "application/pdf" {
let encoded = attachment::encode_base64(data);
Some(ContentBlock::Document(DocumentBlock::new_with_base64_pdf(
Base64PdfSource::new(encoded),
)))
} else if mime_type.starts_with("text/") {
match String::from_utf8(data.clone()) {
Ok(text) => Some(ContentBlock::Document(
DocumentBlock::new_with_plain_text(PlainTextSource::new(text)),
)),
Err(_) => Some(ContentBlock::Text(TextBlock::new(
attachment::inline_attachment_to_text(mime_type, data),
))),
}
} else {
Some(ContentBlock::Text(TextBlock::new(attachment::inline_attachment_to_text(
mime_type, data,
))))
}
}
Part::FileData { mime_type, file_uri } => {
if mime_type == "application/pdf" {
Some(ContentBlock::Document(DocumentBlock::new_with_url_pdf(
UrlPdfSource::new(file_uri.clone()),
)))
} else if matches!(
mime_type.as_str(),
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
) {
Some(ContentBlock::Image(ImageBlock::new_with_url(UrlImageSource::new(
file_uri.clone(),
))))
} else {
Some(ContentBlock::Text(TextBlock::new(attachment::file_attachment_to_text(
mime_type, file_uri,
))))
}
}
Part::Thinking { thinking, .. } => {
if thinking.is_empty() {
None
} else {
Some(ContentBlock::Text(TextBlock::new(thinking.clone())))
}
}
Part::ServerToolCall { server_tool_call } => serde_json::from_value::<
adk_anthropic::ServerToolUseBlock,
>(server_tool_call.clone())
.ok()
.map(ContentBlock::ServerToolUse),
Part::ServerToolResponse { server_tool_response } => {
serde_json::from_value::<adk_anthropic::WebSearchToolResultBlock>(
server_tool_response.clone(),
)
.ok()
.map(ContentBlock::WebSearchToolResult)
}
})
.collect();
let blocks = if blocks.is_empty() && role == MessageRole::Assistant {
vec![ContentBlock::Text(TextBlock::new(" ".to_string()))]
} else if blocks.is_empty() {
vec![ContentBlock::Text(TextBlock::new("".to_string()))]
} else {
blocks
};
Ok(MessageParam::new_with_blocks(blocks, role))
}
pub fn convert_tools(
tools: &HashMap<String, Value>,
) -> Result<Vec<ToolUnionParam>, ConversionError> {
tools
.iter()
.map(|(name, decl)| {
if let Some(provider_tool) = decl.get("x-adk-anthropic-tool") {
return serde_json::from_value::<ToolUnionParam>(provider_tool.clone()).map_err(
|error| {
ConversionError::InvalidToolDeclaration(format!(
"failed to deserialize Anthropic native tool '{name}': {error}"
))
},
);
}
let description = decl.get("description").and_then(|d| d.as_str()).map(String::from);
let input_schema = decl.get("parameters").cloned().unwrap_or(serde_json::json!({
"type": "object",
"properties": {}
}));
let mut tool_param = ToolParam::new(name.clone(), input_schema);
if let Some(desc) = description {
tool_param = tool_param.with_description(desc);
}
Ok(ToolUnionParam::CustomTool(tool_param))
})
.collect()
}
pub fn from_anthropic_message(message: &Message) -> (LlmResponse, HashMap<String, String>) {
let mut parts = Vec::new();
for block in &message.content {
match block {
ContentBlock::Text(text_block) => {
if !text_block.text.is_empty() {
parts.push(Part::Text { text: text_block.text.clone() });
}
}
ContentBlock::ToolUse(tool_use) => {
parts.push(Part::FunctionCall {
name: tool_use.name.clone(),
args: tool_use.input.clone(),
id: Some(tool_use.id.clone()),
thought_signature: None,
});
}
ContentBlock::Thinking(thinking_block) => {
if !thinking_block.thinking.is_empty() {
parts.push(Part::Thinking {
thinking: thinking_block.thinking.clone(),
signature: if thinking_block.signature.is_empty() {
None
} else {
Some(thinking_block.signature.clone())
},
});
}
}
ContentBlock::ServerToolUse(server_tool_use) => {
if let Ok(val) = serde_json::to_value(server_tool_use) {
parts.push(Part::ServerToolCall { server_tool_call: val });
}
}
ContentBlock::WebSearchToolResult(web_search_result) => {
if let Ok(val) = serde_json::to_value(web_search_result) {
parts.push(Part::ServerToolResponse { server_tool_response: val });
}
}
_ => {}
}
}
let content =
if parts.is_empty() { None } else { Some(Content { role: "model".to_string(), parts }) };
let usage_metadata = Some(UsageMetadata {
prompt_token_count: message.usage.input_tokens,
candidates_token_count: message.usage.output_tokens,
total_token_count: (message.usage.input_tokens + message.usage.output_tokens),
cache_read_input_token_count: message.usage.cache_read_input_tokens,
cache_creation_input_token_count: message.usage.cache_creation_input_tokens,
..Default::default()
});
let finish_reason = message.stop_reason.as_ref().map(|sr| match sr {
StopReason::EndTurn => FinishReason::Stop,
StopReason::MaxTokens => FinishReason::MaxTokens,
StopReason::StopSequence => FinishReason::Stop,
StopReason::ToolUse => FinishReason::Stop,
_ => FinishReason::Stop,
});
let cache_meta = extract_cache_usage(&message.usage);
(
LlmResponse {
content,
usage_metadata,
finish_reason,
citation_metadata: None,
partial: false,
turn_complete: true,
interrupted: false,
error_code: None,
error_message: None,
provider_metadata: None,
},
cache_meta,
)
}
pub fn from_text_delta(text: &str) -> LlmResponse {
LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts: vec![Part::Text { text: text.to_string() }],
}),
usage_metadata: None,
finish_reason: None,
citation_metadata: None,
partial: true,
turn_complete: false,
interrupted: false,
error_code: None,
error_message: None,
provider_metadata: None,
}
}
pub fn from_thinking_delta(thinking_text: &str) -> LlmResponse {
LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts: vec![Part::Thinking { thinking: thinking_text.to_string(), signature: None }],
}),
partial: true,
turn_complete: false,
..Default::default()
}
}
pub fn from_stream_error(error_type: &str, message: &str) -> LlmResponse {
LlmResponse {
content: None,
usage_metadata: None,
finish_reason: None,
citation_metadata: None,
partial: false,
turn_complete: true,
interrupted: false,
error_code: Some(error_type.to_string()),
error_message: Some(message.to_string()),
provider_metadata: None,
}
}
pub fn extract_cache_usage(usage: &adk_anthropic::Usage) -> HashMap<String, String> {
let mut metadata = HashMap::new();
if let Some(tokens) = usage.cache_creation_input_tokens {
metadata.insert("anthropic.cache_creation_input_tokens".to_string(), tokens.to_string());
}
if let Some(tokens) = usage.cache_read_input_tokens {
metadata.insert("anthropic.cache_read_input_tokens".to_string(), tokens.to_string());
}
metadata
}
#[allow(clippy::too_many_arguments)]
pub fn build_message_params(
model: &str,
max_tokens: u32,
messages: Vec<MessageParam>,
tools: Vec<ToolUnionParam>,
system_prompt: Option<String>,
temperature: Option<f32>,
top_p: Option<f32>,
top_k: Option<i32>,
prompt_caching: bool,
thinking: Option<&super::config::ThinkingMode>,
effort: Option<super::config::Effort>,
fast_mode: bool,
inference_geo: Option<&str>,
service_tier: Option<&str>,
context_management: Option<&ContextManagement>,
) -> MessageCreateParams {
let mut params =
MessageCreateParams::new(max_tokens, messages, Model::Custom(model.to_string()));
if !tools.is_empty() {
params.tools = Some(tools);
}
if let Some(sys) = system_prompt {
if prompt_caching {
let block = TextBlock::new(sys).with_cache_control(CacheControlEphemeral::new());
params.system = Some(SystemPrompt::from_blocks(vec![block]));
} else {
params.system = Some(SystemPrompt::from_string(sys));
}
}
if let Some(temp) = temperature {
params.temperature = Some(temp);
}
if let Some(p) = top_p {
params.top_p = Some(p);
}
if let Some(k) = top_k {
params.top_k = Some(k as u32);
}
match thinking {
Some(super::config::ThinkingMode::Enabled { budget_tokens }) => {
params.thinking = Some(adk_anthropic::ThinkingConfig::enabled(*budget_tokens));
}
Some(super::config::ThinkingMode::Adaptive) => {
params.thinking = Some(adk_anthropic::ThinkingConfig::adaptive());
}
None => {}
}
if let Some(effort) = effort {
let level = match effort {
super::config::Effort::Low => adk_anthropic::EffortLevel::Low,
super::config::Effort::Medium => adk_anthropic::EffortLevel::Medium,
super::config::Effort::High => adk_anthropic::EffortLevel::High,
super::config::Effort::Max => adk_anthropic::EffortLevel::Max,
};
params.output_config = Some(adk_anthropic::OutputConfig::with_effort(level));
}
if fast_mode {
params.speed = Some(adk_anthropic::SpeedMode::Fast);
}
if let Some(geo) = inference_geo {
params.inference_geo = Some(geo.to_string());
}
if let Some(tier) = service_tier {
params.service_tier = Some(tier.to_string());
}
if prompt_caching {
params.cache_control = Some(CacheControlEphemeral::new());
}
if let Some(cm) = context_management {
params.context_management = Some(cm.clone());
}
params
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_to_message_user() {
let content = Content {
role: "user".to_string(),
parts: vec![Part::Text { text: "Hello".to_string() }],
};
let msg = content_to_message(&content, false).unwrap();
assert!(matches!(msg.role, MessageRole::User));
}
#[test]
fn test_content_to_message_assistant() {
let content = Content {
role: "model".to_string(),
parts: vec![Part::Text { text: "Hi there".to_string() }],
};
let msg = content_to_message(&content, false).unwrap();
assert!(matches!(msg.role, MessageRole::Assistant));
}
#[test]
fn test_content_to_message_with_inline_image() {
let content = Content {
role: "user".to_string(),
parts: vec![
Part::Text { text: "What is in this image?".to_string() },
Part::InlineData {
mime_type: "image/png".to_string(),
data: vec![0x89, 0x50, 0x4E, 0x47],
},
],
};
let msg = content_to_message(&content, false).unwrap();
assert!(matches!(msg.role, MessageRole::User));
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 2);
assert_eq!(content_blocks[0]["type"], "text");
assert_eq!(content_blocks[0]["text"], "What is in this image?");
assert_eq!(content_blocks[1]["type"], "image");
assert_eq!(content_blocks[1]["source"]["type"], "base64");
assert_eq!(content_blocks[1]["source"]["media_type"], "image/png");
assert!(!content_blocks[1]["source"]["data"].as_str().unwrap().is_empty());
}
#[test]
fn test_content_to_message_unsupported_mime_type_falls_back_to_text() {
let content = Content {
role: "user".to_string(),
parts: vec![
Part::Text { text: "Check this".to_string() },
Part::InlineData {
mime_type: "audio/wav".to_string(), data: vec![0x52, 0x49, 0x46, 0x46],
},
],
};
let msg = content_to_message(&content, false).unwrap();
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 2);
assert_eq!(content_blocks[0]["type"], "text");
assert_eq!(content_blocks[1]["type"], "text");
assert!(content_blocks[1]["text"].as_str().unwrap_or_default().contains("audio/wav"));
}
#[test]
fn test_content_to_message_multiple_images() {
let content = Content {
role: "user".to_string(),
parts: vec![
Part::Text { text: "Compare".to_string() },
Part::InlineData { mime_type: "image/jpeg".to_string(), data: vec![0xFF, 0xD8] },
Part::InlineData { mime_type: "image/webp".to_string(), data: vec![0x52, 0x49] },
],
};
let msg = content_to_message(&content, false).unwrap();
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 3); assert_eq!(content_blocks[1]["source"]["media_type"], "image/jpeg");
assert_eq!(content_blocks[2]["source"]["media_type"], "image/webp");
}
#[test]
fn test_content_to_message_pdf_inline_data_maps_to_document_block() {
let content = Content {
role: "user".to_string(),
parts: vec![Part::InlineData {
mime_type: "application/pdf".to_string(),
data: b"%PDF-1.4".to_vec(),
}],
};
let msg = content_to_message(&content, false).unwrap();
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 1);
assert_eq!(content_blocks[0]["type"], "document");
assert_eq!(content_blocks[0]["source"]["type"], "base64");
assert_eq!(content_blocks[0]["source"]["media_type"], "application/pdf");
}
#[test]
fn test_content_to_message_pdf_file_uri_maps_to_document_block() {
let content = Content {
role: "user".to_string(),
parts: vec![Part::FileData {
mime_type: "application/pdf".to_string(),
file_uri: "https://example.com/test.pdf".to_string(),
}],
};
let msg = content_to_message(&content, false).unwrap();
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 1);
assert_eq!(content_blocks[0]["type"], "document");
assert_eq!(content_blocks[0]["source"]["type"], "url");
assert_eq!(content_blocks[0]["source"]["url"], "https://example.com/test.pdf");
}
#[test]
fn test_content_to_message_image_file_data_maps_to_url_image() {
let content = Content {
role: "user".to_string(),
parts: vec![
Part::Text { text: "Describe this".to_string() },
Part::FileData {
mime_type: "image/jpeg".to_string(),
file_uri: "https://example.com/photo.jpg".to_string(),
},
],
};
let msg = content_to_message(&content, false).unwrap();
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 2);
assert_eq!(content_blocks[0]["type"], "text");
assert_eq!(content_blocks[1]["type"], "image");
assert_eq!(content_blocks[1]["source"]["type"], "url");
assert_eq!(content_blocks[1]["source"]["url"], "https://example.com/photo.jpg");
}
#[test]
fn test_content_to_message_webp_file_data_maps_to_url_image() {
let content = Content {
role: "user".to_string(),
parts: vec![Part::FileData {
mime_type: "image/webp".to_string(),
file_uri: "https://example.com/image.webp".to_string(),
}],
};
let msg = content_to_message(&content, false).unwrap();
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 1);
assert_eq!(content_blocks[0]["type"], "image");
assert_eq!(content_blocks[0]["source"]["url"], "https://example.com/image.webp");
}
#[test]
fn test_content_to_message_unsupported_file_data_falls_back_to_text() {
let content = Content {
role: "user".to_string(),
parts: vec![Part::FileData {
mime_type: "audio/wav".to_string(),
file_uri: "https://example.com/audio.wav".to_string(),
}],
};
let msg = content_to_message(&content, false).unwrap();
let json = serde_json::to_value(&msg).unwrap();
let content_blocks = json["content"].as_array().unwrap();
assert_eq!(content_blocks.len(), 1);
assert_eq!(content_blocks[0]["type"], "text");
assert!(content_blocks[0]["text"].as_str().unwrap().contains("audio/wav"));
}
#[test]
fn test_convert_tools() {
let mut tools = HashMap::new();
tools.insert(
"get_weather".to_string(),
serde_json::json!({
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string" }
}
}
}),
);
let claude_tools = convert_tools(&tools).expect("tool conversion should succeed");
assert_eq!(claude_tools.len(), 1);
}
#[test]
fn test_convert_tools_supports_native_anthropic_tool_declarations() {
let mut tools = HashMap::new();
tools.insert(
"bash".to_string(),
serde_json::json!({
"x-adk-anthropic-tool": {
"type": "bash_20250124",
"name": "bash"
}
}),
);
let claude_tools = convert_tools(&tools).expect("tool conversion should succeed");
assert_eq!(claude_tools.len(), 1);
let value = serde_json::to_value(&claude_tools[0]).expect("tool should serialize");
assert_eq!(value["type"], "bash_20250124");
assert_eq!(value["name"], "bash");
}
#[test]
fn test_function_response_string_is_not_double_encoded() {
let content = Content {
role: "function".to_string(),
parts: vec![Part::FunctionResponse {
function_response: adk_core::FunctionResponseData::new(
"bash",
Value::String("hello".to_string()),
),
id: Some("tool_123".to_string()),
}],
};
let message = content_to_message(&content, false).expect("content should convert");
let json = serde_json::to_value(message).expect("message should serialize");
let block = &json["content"][0];
assert_eq!(block["type"], "tool_result");
assert_eq!(block["content"], "hello");
}
#[test]
fn test_from_anthropic_message_with_thinking_block() {
use adk_anthropic::{ThinkingBlock, Usage};
let message = Message {
id: "msg_123".to_string(),
model: Model::Custom("claude-3-5-sonnet-20241022".to_string()),
role: MessageRole::Assistant,
container: None,
content: vec![
ContentBlock::Thinking(ThinkingBlock::new(
"Let me reason through this step by step...",
"sig_abc123",
)),
ContentBlock::Text(TextBlock::new("The answer is 42.")),
],
stop_reason: Some(StopReason::EndTurn),
stop_sequence: None,
r#type: "message".to_string(),
usage: Usage {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
cache_creation_input_tokens_1h: None,
server_tool_use: None,
},
};
let (response, _cache_meta) = from_anthropic_message(&message);
let content = response.content.expect("should have content");
assert_eq!(content.parts.len(), 2);
assert!(content.parts[0].is_thinking());
assert_eq!(
content.parts[0].thinking_text(),
Some("Let me reason through this step by step...")
);
assert!(!content.parts[1].is_thinking());
assert_eq!(content.parts[1].text(), Some("The answer is 42."));
}
#[test]
fn test_from_anthropic_message_empty_thinking_block_skipped() {
use adk_anthropic::{ThinkingBlock, Usage};
let message = Message {
id: "msg_456".to_string(),
model: Model::Custom("claude-3-5-sonnet-20241022".to_string()),
role: MessageRole::Assistant,
container: None,
content: vec![
ContentBlock::Thinking(ThinkingBlock::new("", "sig_empty")),
ContentBlock::Text(TextBlock::new("Just text.")),
],
stop_reason: Some(StopReason::EndTurn),
stop_sequence: None,
r#type: "message".to_string(),
usage: Usage {
input_tokens: 5,
output_tokens: 10,
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
cache_creation_input_tokens_1h: None,
server_tool_use: None,
},
};
let (response, _) = from_anthropic_message(&message);
let content = response.content.expect("should have content");
assert_eq!(content.parts.len(), 1);
assert_eq!(content.parts[0].text(), Some("Just text."));
}
}