use crate::llm::protocol::{FromProvider, ProtocolError, ProtocolResult, ToProvider};
use crate::llm::providers::anthropic::api_types::*;
use bamboo_domain::{FunctionSchema, ToolSchema};
use bamboo_domain::{Message, Role};
use serde_json::Value;
#[cfg(test)]
use bamboo_domain::{FunctionCall, ToolCall};
pub struct AnthropicProtocol;
impl FromProvider<AnthropicMessage> for Message {
fn from_provider(msg: AnthropicMessage) -> ProtocolResult<Self> {
let role = convert_anthropic_role_to_internal(&msg.role);
let content = match msg.content {
AnthropicContent::Text(text) => text,
AnthropicContent::Blocks(blocks) => extract_text_from_anthropic_blocks(blocks)?,
};
Ok(Message {
id: String::new(),
role,
content,
reasoning: None,
content_parts: None,
image_ocr: None,
phase: None,
tool_calls: None, tool_call_id: None,
tool_success: None,
compressed: false,
compressed_by_event_id: None,
never_compress: false,
compression_level: 0,
created_at: chrono::Utc::now(),
metadata: None,
})
}
}
impl FromProvider<AnthropicTool> for ToolSchema {
fn from_provider(tool: AnthropicTool) -> ProtocolResult<Self> {
Ok(ToolSchema {
schema_type: "function".to_string(),
function: FunctionSchema {
name: tool.name,
description: tool.description.unwrap_or_default(),
parameters: tool.input_schema,
},
})
}
}
pub struct AnthropicRequest {
pub system: Option<String>,
pub messages: Vec<AnthropicMessage>,
}
fn preview_for_log(value: &str, max_chars: usize) -> String {
let mut iter = value.chars();
let mut preview = String::new();
for _ in 0..max_chars {
match iter.next() {
Some(ch) => preview.push(ch),
None => break,
}
}
if iter.next().is_some() {
preview.push_str("...");
}
preview.replace('\n', "\\n").replace('\r', "\\r")
}
impl ToProvider<AnthropicRequest> for Vec<Message> {
fn to_provider(&self) -> ProtocolResult<AnthropicRequest> {
let mut system_parts = Vec::new();
let mut anthropic_messages = Vec::new();
for msg in self {
match msg.role {
Role::System => {
system_parts.push(msg.content.clone());
}
_ => {
anthropic_messages.push(msg.to_provider()?);
}
}
}
let system = if system_parts.is_empty() {
None
} else {
Some(system_parts.join("\n\n"))
};
Ok(AnthropicRequest {
system,
messages: anthropic_messages,
})
}
}
impl ToProvider<AnthropicMessage> for Message {
fn to_provider(&self) -> ProtocolResult<AnthropicMessage> {
let role = convert_internal_role_to_anthropic(&self.role);
let content = match self.role {
Role::System => {
AnthropicContent::Text(self.content.clone())
}
Role::User => {
let mut blocks = Vec::new();
if let Some(parts) = self.content_parts.as_ref() {
for part in parts {
if let Some(block) = content_part_to_anthropic_block(part) {
blocks.push(block);
}
}
}
if blocks.is_empty() {
blocks.push(AnthropicContentBlock::Text {
text: self.content.clone(),
});
}
AnthropicContent::Blocks(blocks)
}
Role::Assistant => {
let mut blocks: Vec<AnthropicContentBlock> = Vec::new();
if let Some(parts) = self.content_parts.as_ref() {
for part in parts {
if let Some(block) = content_part_to_anthropic_block(part) {
blocks.push(block);
}
}
} else if !self.content.is_empty() {
blocks.push(AnthropicContentBlock::Text {
text: self.content.clone(),
});
}
if let Some(tool_calls) = &self.tool_calls {
for tc in tool_calls {
let raw_arguments = tc.function.arguments.trim();
let input: Value = match serde_json::from_str(raw_arguments) {
Ok(parsed) => parsed,
Err(error) => {
tracing::warn!(
"Anthropic protocol conversion fallback to string input due to invalid JSON arguments: tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", error={}",
tc.id,
tc.function.name,
raw_arguments.len(),
preview_for_log(raw_arguments, 180),
error
);
Value::String(tc.function.arguments.clone())
}
};
blocks.push(AnthropicContentBlock::ToolUse {
id: tc.id.clone(),
name: tc.function.name.clone(),
input,
});
}
}
AnthropicContent::Blocks(blocks)
}
Role::Tool => {
let tool_use_id = self
.tool_call_id
.clone()
.ok_or_else(|| ProtocolError::MissingField("tool_call_id".to_string()))?;
AnthropicContent::Blocks(vec![AnthropicContentBlock::ToolResult {
tool_use_id,
content: Value::String(self.content.clone()),
}])
}
};
Ok(AnthropicMessage { role, content })
}
}
impl ToProvider<AnthropicTool> for ToolSchema {
fn to_provider(&self) -> ProtocolResult<AnthropicTool> {
Ok(AnthropicTool {
name: self.function.name.clone(),
description: Some(self.function.description.clone()),
input_schema: self.function.parameters.clone(),
})
}
}
#[cfg(test)]
pub struct AnthropicResponseConverter;
#[cfg(test)]
impl AnthropicResponseConverter {
pub fn convert_response(response: AnthropicMessagesResponse) -> ProtocolResult<Message> {
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in response.content {
match block {
AnthropicResponseContentBlock::Text { text } => {
text_parts.push(text);
}
AnthropicResponseContentBlock::ToolUse { id, name, input } => {
tool_calls.push(ToolCall {
id,
tool_type: "function".to_string(),
function: FunctionCall {
name,
arguments: serde_json::to_string(&input)
.unwrap_or_else(|_| String::new()),
},
});
}
}
}
let content = text_parts.join("");
let tool_calls = if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
};
Ok(Message {
id: response.id,
role: Role::Assistant,
content,
reasoning: None,
content_parts: None,
image_ocr: None,
phase: None,
tool_calls,
tool_call_id: None,
tool_success: None,
compressed: false,
compressed_by_event_id: None,
never_compress: false,
compression_level: 0,
created_at: chrono::Utc::now(),
metadata: None,
})
}
}
fn convert_anthropic_role_to_internal(role: &AnthropicRole) -> Role {
match role {
AnthropicRole::User => Role::User,
AnthropicRole::Assistant => Role::Assistant,
AnthropicRole::System => Role::System,
}
}
fn convert_internal_role_to_anthropic(role: &Role) -> AnthropicRole {
match role {
Role::User => AnthropicRole::User,
Role::Assistant => AnthropicRole::Assistant,
Role::System => AnthropicRole::User,
Role::Tool => AnthropicRole::User,
}
}
fn extract_text_from_anthropic_blocks(
blocks: Vec<AnthropicContentBlock>,
) -> ProtocolResult<String> {
let mut texts = Vec::new();
for block in blocks {
match block {
AnthropicContentBlock::Text { text } => texts.push(text),
AnthropicContentBlock::Image { .. } => {
}
AnthropicContentBlock::ToolUse { .. } => {
}
AnthropicContentBlock::ToolResult { content, .. } => {
match content {
Value::String(s) => texts.push(s),
Value::Array(arr) => {
for item in arr {
if let Some(obj) = item.as_object() {
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
texts.push(text.to_string());
}
}
}
}
_ => {}
}
}
}
}
Ok(texts.join("\n"))
}
fn content_part_to_anthropic_block(
part: &bamboo_domain::MessagePart,
) -> Option<AnthropicContentBlock> {
match part {
bamboo_domain::MessagePart::Text { text } => {
Some(AnthropicContentBlock::Text { text: text.clone() })
}
bamboo_domain::MessagePart::ImageUrl { image_url } => {
let trimmed = image_url.url.trim();
if trimmed.is_empty() {
return None;
}
if let Some((media_type, data)) = parse_data_url_base64(trimmed) {
return Some(AnthropicContentBlock::Image {
source: AnthropicImageSource::Base64 { media_type, data },
});
}
Some(AnthropicContentBlock::Image {
source: AnthropicImageSource::Url {
url: trimmed.to_string(),
},
})
}
}
}
fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
let rest = url.strip_prefix("data:")?;
let (meta, data) = rest.split_once(',')?;
let data = data.trim();
if data.is_empty() {
return None;
}
let mut media_type = "application/octet-stream";
let mut is_base64 = false;
for (idx, seg) in meta.split(';').enumerate() {
let segment = seg.trim();
if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
media_type = segment;
}
if segment.eq_ignore_ascii_case("base64") {
is_base64 = true;
}
}
if !is_base64 {
return None;
}
Some((media_type.to_string(), data.to_string()))
}
#[cfg(test)]
pub trait AnthropicExt: Sized {
fn into_internal(self) -> ProtocolResult<Message>;
fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage>;
}
#[cfg(test)]
impl AnthropicExt for AnthropicMessage {
fn into_internal(self) -> ProtocolResult<Message> {
Message::from_provider(self)
}
fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage> {
unimplemented!("Use clone for now")
}
}
#[cfg(test)]
impl AnthropicExt for Message {
fn into_internal(self) -> ProtocolResult<Message> {
Ok(self)
}
fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage> {
self.to_provider()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_anthropic_to_internal_text_message() {
let anthropic_msg = AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicContent::Text("Hello".to_string()),
};
let internal: Message = anthropic_msg.into_internal().unwrap();
assert_eq!(internal.role, Role::User);
assert_eq!(internal.content, "Hello");
}
#[test]
fn test_internal_to_anthropic_user_message() {
let internal = Message::user("Hello");
let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
assert_eq!(anthropic.role, AnthropicRole::User);
match anthropic.content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
assert!(
matches!(blocks[0], AnthropicContentBlock::Text { text: ref t } if t == "Hello")
);
}
_ => panic!("Expected Blocks content"),
}
}
#[test]
fn test_internal_to_anthropic_system_message_extraction() {
let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
let request: AnthropicRequest = messages.to_provider().unwrap();
assert_eq!(request.system, Some("You are helpful".to_string()));
assert_eq!(request.messages.len(), 1);
assert_eq!(request.messages[0].role, AnthropicRole::User);
}
#[test]
fn test_internal_to_anthropic_with_tool_call() {
let tool_call = ToolCall {
id: "toolu_1".to_string(),
tool_type: "function".to_string(),
function: FunctionCall {
name: "search".to_string(),
arguments: r#"{"q":"test"}"#.to_string(),
},
};
let internal = Message::assistant("Let me search", Some(vec![tool_call]));
let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
match anthropic.content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2);
assert!(matches!(blocks[0], AnthropicContentBlock::Text { .. }));
assert!(
matches!(blocks[1], AnthropicContentBlock::ToolUse { ref id, ref name, .. } if id == "toolu_1" && name == "search")
);
}
_ => panic!("Expected Blocks content"),
}
}
#[test]
fn test_tool_message_to_anthropic() {
let internal = Message::tool_result("toolu_1", "Result here");
let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
assert_eq!(anthropic.role, AnthropicRole::User);
match anthropic.content {
AnthropicContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
assert!(
matches!(blocks[0], AnthropicContentBlock::ToolResult { ref tool_use_id, .. } if tool_use_id == "toolu_1")
);
}
_ => panic!("Expected Blocks content"),
}
}
#[test]
fn test_tool_schema_conversion() {
let anthropic_tool = AnthropicTool {
name: "search".to_string(),
description: Some("Search the web".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"q": { "type": "string" }
}
}),
};
let internal_schema: ToolSchema =
ToolSchema::from_provider(anthropic_tool.clone()).unwrap();
assert_eq!(internal_schema.function.name, "search");
let roundtrip: AnthropicTool = internal_schema.to_provider().unwrap();
assert_eq!(roundtrip.name, "search");
assert_eq!(roundtrip.description, Some("Search the web".to_string()));
}
#[test]
fn test_anthropic_response_to_internal() {
let response = AnthropicMessagesResponse {
id: "msg_1".to_string(),
response_type: "message".to_string(),
role: "assistant".to_string(),
content: vec![AnthropicResponseContentBlock::Text {
text: "Hello, world!".to_string(),
}],
model: "claude-3-sonnet".to_string(),
stop_reason: "end_turn".to_string(),
stop_sequence: None,
usage: AnthropicUsage {
input_tokens: 10,
output_tokens: 5,
},
};
let internal = AnthropicResponseConverter::convert_response(response).unwrap();
assert_eq!(internal.role, Role::Assistant);
assert_eq!(internal.content, "Hello, world!");
assert!(internal.tool_calls.is_none());
}
#[test]
fn test_anthropic_response_with_tool_use() {
let response = AnthropicMessagesResponse {
id: "msg_1".to_string(),
response_type: "message".to_string(),
role: "assistant".to_string(),
content: vec![
AnthropicResponseContentBlock::Text {
text: "Let me help you search.".to_string(),
},
AnthropicResponseContentBlock::ToolUse {
id: "toolu_1".to_string(),
name: "search".to_string(),
input: serde_json::json!({"q": "test"}),
},
],
model: "claude-3-sonnet".to_string(),
stop_reason: "tool_use".to_string(),
stop_sequence: None,
usage: AnthropicUsage {
input_tokens: 10,
output_tokens: 5,
},
};
let internal = AnthropicResponseConverter::convert_response(response).unwrap();
assert_eq!(internal.content, "Let me help you search.");
assert!(internal.tool_calls.is_some());
let tool_calls = internal.tool_calls.unwrap();
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id, "toolu_1");
assert_eq!(tool_calls[0].function.name, "search");
}
}