1use crate::protocol::{FromProvider, ProtocolError, ProtocolResult, ToProvider};
4use crate::providers::anthropic::api_types::*;
5use crate::providers::common::log_identity;
6use bamboo_domain::{FunctionSchema, ToolSchema};
7use bamboo_domain::{Message, Role};
8use serde_json::Value;
9
10#[cfg(test)]
11use bamboo_domain::{FunctionCall, ToolCall};
12pub struct AnthropicProtocol;
14
15impl FromProvider<AnthropicMessage> for Message {
20 fn from_provider(msg: AnthropicMessage) -> ProtocolResult<Self> {
21 let role = convert_anthropic_role_to_internal(&msg.role);
22
23 let content = match msg.content {
24 AnthropicContent::Text(text) => text,
25 AnthropicContent::Blocks(blocks) => extract_text_from_anthropic_blocks(blocks)?,
26 };
27
28 Ok(Message {
29 id: String::new(),
30 role,
31 content,
32 reasoning: None,
33 reasoning_signature: None,
34 content_parts: None,
35 image_ocr: None,
36 phase: None,
37 tool_calls: None, tool_call_id: None,
39 tool_success: None,
40 compressed: false,
41 compressed_by_event_id: None,
42 never_compress: false,
43 compression_level: 0,
44 created_at: chrono::Utc::now(),
45 metadata: None,
46 })
47 }
48}
49
50impl FromProvider<AnthropicTool> for ToolSchema {
51 fn from_provider(tool: AnthropicTool) -> ProtocolResult<Self> {
52 Ok(ToolSchema {
53 schema_type: "function".to_string(),
54 function: FunctionSchema {
55 name: tool.name,
56 description: tool.description.unwrap_or_default(),
57 parameters: tool.input_schema,
58 },
59 })
60 }
61}
62
63pub struct AnthropicRequest {
72 pub system: Option<String>,
73 pub messages: Vec<AnthropicMessage>,
74}
75
76impl ToProvider<AnthropicRequest> for Vec<Message> {
77 fn to_provider(&self) -> ProtocolResult<AnthropicRequest> {
78 let mut system_parts = Vec::new();
79 let mut anthropic_messages = Vec::new();
80
81 for msg in self {
82 match msg.role {
83 Role::System => {
84 system_parts.push(msg.content.clone());
85 }
86 _ => {
87 anthropic_messages.push(msg.to_provider()?);
88 }
89 }
90 }
91
92 let system = if system_parts.is_empty() {
93 None
94 } else {
95 Some(system_parts.join("\n\n"))
96 };
97
98 Ok(AnthropicRequest {
99 system,
100 messages: anthropic_messages,
101 })
102 }
103}
104
105impl ToProvider<AnthropicMessage> for Message {
106 fn to_provider(&self) -> ProtocolResult<AnthropicMessage> {
107 let role = convert_internal_role_to_anthropic(&self.role);
108
109 let content = match self.role {
110 Role::System => {
111 AnthropicContent::Text(self.content.clone())
113 }
114 Role::User => {
115 let mut blocks = Vec::new();
116 if let Some(parts) = self.content_parts.as_ref() {
117 for part in parts {
118 if let Some(block) = content_part_to_anthropic_block(part) {
119 blocks.push(block);
120 }
121 }
122 }
123 if blocks.is_empty() {
124 blocks.push(AnthropicContentBlock::Text {
125 text: self.content.clone(),
126 });
127 }
128 AnthropicContent::Blocks(blocks)
129 }
130 Role::Assistant => {
131 let mut blocks: Vec<AnthropicContentBlock> = Vec::new();
132
133 if let Some(parts) = self.content_parts.as_ref() {
134 for part in parts {
135 if let Some(block) = content_part_to_anthropic_block(part) {
136 blocks.push(block);
137 }
138 }
139 } else if !self.content.is_empty() {
140 blocks.push(AnthropicContentBlock::Text {
141 text: self.content.clone(),
142 });
143 }
144
145 if let Some(tool_calls) = &self.tool_calls {
147 for tc in tool_calls {
148 let raw_arguments = tc.function.arguments.trim();
149 let input: Value = match serde_json::from_str(raw_arguments) {
150 Ok(parsed) => parsed,
151 Err(error) => {
152 tracing::warn!(
153 "Anthropic protocol conversion fallback to string input due to invalid JSON arguments: tool_call_hash={}, tool_name_hash={}, args_len={}, error_kind={:?}",
154 log_identity(&tc.id),
155 log_identity(&tc.function.name),
156 raw_arguments.len(),
157 error.classify()
158 );
159 Value::String(tc.function.arguments.clone())
160 }
161 };
162
163 blocks.push(AnthropicContentBlock::ToolUse {
164 id: tc.id.clone(),
165 name: tc.function.name.clone(),
166 input,
167 });
168 }
169 }
170
171 AnthropicContent::Blocks(blocks)
172 }
173 Role::Tool => {
174 let tool_use_id = self
176 .tool_call_id
177 .clone()
178 .ok_or_else(|| ProtocolError::MissingField("tool_call_id".to_string()))?;
179
180 AnthropicContent::Blocks(vec![AnthropicContentBlock::ToolResult {
181 tool_use_id,
182 content: Value::String(self.content.clone()),
183 }])
184 }
185 };
186
187 Ok(AnthropicMessage { role, content })
188 }
189}
190
191impl ToProvider<AnthropicTool> for ToolSchema {
192 fn to_provider(&self) -> ProtocolResult<AnthropicTool> {
193 Ok(AnthropicTool {
194 name: self.function.name.clone(),
195 description: Some(self.function.description.clone()),
196 input_schema: crate::providers::common::tool_schema::canonicalize_json_value(
197 &self.function.parameters,
198 ),
199 })
200 }
201}
202
203#[cfg(test)]
209pub struct AnthropicResponseConverter;
210
211#[cfg(test)]
212impl AnthropicResponseConverter {
213 pub fn convert_response(response: AnthropicMessagesResponse) -> ProtocolResult<Message> {
215 let mut text_parts = Vec::new();
217 let mut tool_calls = Vec::new();
218
219 for block in response.content {
220 match block {
221 AnthropicResponseContentBlock::Text { text } => {
222 text_parts.push(text);
223 }
224 AnthropicResponseContentBlock::ToolUse { id, name, input } => {
225 tool_calls.push(ToolCall {
226 id,
227 tool_type: "function".to_string(),
228 function: FunctionCall {
229 name,
230 arguments: serde_json::to_string(&input)
231 .unwrap_or_else(|_| String::new()),
232 },
233 });
234 }
235 }
236 }
237
238 let content = text_parts.join("");
239 let tool_calls = if tool_calls.is_empty() {
240 None
241 } else {
242 Some(tool_calls)
243 };
244
245 Ok(Message {
246 id: response.id,
247 role: Role::Assistant,
248 content,
249 reasoning: None,
250 reasoning_signature: None,
251 content_parts: None,
252 image_ocr: None,
253 phase: None,
254 tool_calls,
255 tool_call_id: None,
256 tool_success: None,
257 compressed: false,
258 compressed_by_event_id: None,
259 never_compress: false,
260 compression_level: 0,
261 created_at: chrono::Utc::now(),
262 metadata: None,
263 })
264 }
265}
266
267fn convert_anthropic_role_to_internal(role: &AnthropicRole) -> Role {
272 match role {
273 AnthropicRole::User => Role::User,
274 AnthropicRole::Assistant => Role::Assistant,
275 AnthropicRole::System => Role::System,
276 }
277}
278
279fn convert_internal_role_to_anthropic(role: &Role) -> AnthropicRole {
280 match role {
281 Role::User => AnthropicRole::User,
282 Role::Assistant => AnthropicRole::Assistant,
283 Role::System => AnthropicRole::User,
285 Role::Tool => AnthropicRole::User,
287 }
288}
289
290fn extract_text_from_anthropic_blocks(
291 blocks: Vec<AnthropicContentBlock>,
292) -> ProtocolResult<String> {
293 let mut texts = Vec::new();
294
295 for block in blocks {
296 match block {
297 AnthropicContentBlock::Text { text } => texts.push(text),
298 AnthropicContentBlock::Image { .. } => {
299 }
301 AnthropicContentBlock::ToolUse { .. } => {
302 }
304 AnthropicContentBlock::ToolResult { content, .. } => {
305 match content {
307 Value::String(s) => texts.push(s),
308 Value::Array(arr) => {
309 for item in arr {
310 if let Some(obj) = item.as_object() {
311 if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
312 texts.push(text.to_string());
313 }
314 }
315 }
316 }
317 _ => {}
318 }
319 }
320 }
321 }
322
323 Ok(texts.join("\n"))
324}
325
326fn content_part_to_anthropic_block(
327 part: &bamboo_domain::MessagePart,
328) -> Option<AnthropicContentBlock> {
329 match part {
330 bamboo_domain::MessagePart::Text { text } => {
331 Some(AnthropicContentBlock::Text { text: text.clone() })
332 }
333 bamboo_domain::MessagePart::ImageUrl { image_url } => {
334 let trimmed = image_url.url.trim();
335 if trimmed.is_empty() {
336 return None;
337 }
338 if let Some((media_type, data)) = parse_data_url_base64(trimmed) {
339 return Some(AnthropicContentBlock::Image {
340 source: AnthropicImageSource::Base64 { media_type, data },
341 });
342 }
343 Some(AnthropicContentBlock::Image {
344 source: AnthropicImageSource::Url {
345 url: trimmed.to_string(),
346 },
347 })
348 }
349 }
350}
351
352fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
353 let rest = url.strip_prefix("data:")?;
354 let (meta, data) = rest.split_once(',')?;
355 let data = data.trim();
356 if data.is_empty() {
357 return None;
358 }
359
360 let mut media_type = "application/octet-stream";
361 let mut is_base64 = false;
362 for (idx, seg) in meta.split(';').enumerate() {
363 let segment = seg.trim();
364 if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
365 media_type = segment;
366 }
367 if segment.eq_ignore_ascii_case("base64") {
368 is_base64 = true;
369 }
370 }
371
372 if !is_base64 {
373 return None;
374 }
375
376 Some((media_type.to_string(), data.to_string()))
377}
378
379#[cfg(test)]
385pub trait AnthropicExt: Sized {
386 fn into_internal(self) -> ProtocolResult<Message>;
387 fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage>;
388}
389
390#[cfg(test)]
391impl AnthropicExt for AnthropicMessage {
392 fn into_internal(self) -> ProtocolResult<Message> {
393 Message::from_provider(self)
394 }
395
396 fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage> {
397 unimplemented!("Use clone for now")
400 }
401}
402
403#[cfg(test)]
404impl AnthropicExt for Message {
405 fn into_internal(self) -> ProtocolResult<Message> {
406 Ok(self)
407 }
408
409 fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage> {
410 self.to_provider()
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_anthropic_to_internal_text_message() {
420 let anthropic_msg = AnthropicMessage {
421 role: AnthropicRole::User,
422 content: AnthropicContent::Text("Hello".to_string()),
423 };
424
425 let internal: Message = anthropic_msg.into_internal().unwrap();
426
427 assert_eq!(internal.role, Role::User);
428 assert_eq!(internal.content, "Hello");
429 }
430
431 #[test]
432 fn test_internal_to_anthropic_user_message() {
433 let internal = Message::user("Hello");
434
435 let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
436
437 assert_eq!(anthropic.role, AnthropicRole::User);
438 match anthropic.content {
439 AnthropicContent::Blocks(blocks) => {
440 assert_eq!(blocks.len(), 1);
441 assert!(
442 matches!(blocks[0], AnthropicContentBlock::Text { text: ref t } if t == "Hello")
443 );
444 }
445 _ => panic!("Expected Blocks content"),
446 }
447 }
448
449 #[test]
450 fn test_internal_to_anthropic_system_message_extraction() {
451 let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
452
453 let request: AnthropicRequest = messages.to_provider().unwrap();
454
455 assert_eq!(request.system, Some("You are helpful".to_string()));
456 assert_eq!(request.messages.len(), 1);
457 assert_eq!(request.messages[0].role, AnthropicRole::User);
458 }
459
460 #[test]
461 fn test_internal_to_anthropic_with_tool_call() {
462 let tool_call = ToolCall {
463 id: "toolu_1".to_string(),
464 tool_type: "function".to_string(),
465 function: FunctionCall {
466 name: "search".to_string(),
467 arguments: r#"{"q":"test"}"#.to_string(),
468 },
469 };
470
471 let internal = Message::assistant("Let me search", Some(vec![tool_call]));
472
473 let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
474
475 match anthropic.content {
476 AnthropicContent::Blocks(blocks) => {
477 assert_eq!(blocks.len(), 2);
478 assert!(matches!(blocks[0], AnthropicContentBlock::Text { .. }));
479 assert!(
480 matches!(blocks[1], AnthropicContentBlock::ToolUse { ref id, ref name, .. } if id == "toolu_1" && name == "search")
481 );
482 }
483 _ => panic!("Expected Blocks content"),
484 }
485 }
486
487 #[test]
488 fn test_tool_message_to_anthropic() {
489 let internal = Message::tool_result("toolu_1", "Result here");
490
491 let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
492
493 assert_eq!(anthropic.role, AnthropicRole::User);
494 match anthropic.content {
495 AnthropicContent::Blocks(blocks) => {
496 assert_eq!(blocks.len(), 1);
497 assert!(
498 matches!(blocks[0], AnthropicContentBlock::ToolResult { ref tool_use_id, .. } if tool_use_id == "toolu_1")
499 );
500 }
501 _ => panic!("Expected Blocks content"),
502 }
503 }
504
505 #[test]
506 fn test_tool_schema_conversion() {
507 let anthropic_tool = AnthropicTool {
508 name: "search".to_string(),
509 description: Some("Search the web".to_string()),
510 input_schema: serde_json::json!({
511 "type": "object",
512 "properties": {
513 "q": { "type": "string" }
514 }
515 }),
516 };
517
518 let internal_schema: ToolSchema =
520 ToolSchema::from_provider(anthropic_tool.clone()).unwrap();
521 assert_eq!(internal_schema.function.name, "search");
522
523 let roundtrip: AnthropicTool = internal_schema.to_provider().unwrap();
525 assert_eq!(roundtrip.name, "search");
526 assert_eq!(roundtrip.description, Some("Search the web".to_string()));
527 }
528
529 #[test]
530 fn test_anthropic_response_to_internal() {
531 let response = AnthropicMessagesResponse {
532 id: "msg_1".to_string(),
533 response_type: "message".to_string(),
534 role: "assistant".to_string(),
535 content: vec![AnthropicResponseContentBlock::Text {
536 text: "Hello, world!".to_string(),
537 }],
538 model: "claude-3-sonnet".to_string(),
539 stop_reason: "end_turn".to_string(),
540 stop_sequence: None,
541 usage: AnthropicUsage {
542 input_tokens: 10,
543 output_tokens: 5,
544 },
545 };
546
547 let internal = AnthropicResponseConverter::convert_response(response).unwrap();
548
549 assert_eq!(internal.role, Role::Assistant);
550 assert_eq!(internal.content, "Hello, world!");
551 assert!(internal.tool_calls.is_none());
552 }
553
554 #[test]
555 fn test_anthropic_response_with_tool_use() {
556 let response = AnthropicMessagesResponse {
557 id: "msg_1".to_string(),
558 response_type: "message".to_string(),
559 role: "assistant".to_string(),
560 content: vec![
561 AnthropicResponseContentBlock::Text {
562 text: "Let me help you search.".to_string(),
563 },
564 AnthropicResponseContentBlock::ToolUse {
565 id: "toolu_1".to_string(),
566 name: "search".to_string(),
567 input: serde_json::json!({"q": "test"}),
568 },
569 ],
570 model: "claude-3-sonnet".to_string(),
571 stop_reason: "tool_use".to_string(),
572 stop_sequence: None,
573 usage: AnthropicUsage {
574 input_tokens: 10,
575 output_tokens: 5,
576 },
577 };
578
579 let internal = AnthropicResponseConverter::convert_response(response).unwrap();
580
581 assert_eq!(internal.content, "Let me help you search.");
582 assert!(internal.tool_calls.is_some());
583 let tool_calls = internal.tool_calls.unwrap();
584 assert_eq!(tool_calls.len(), 1);
585 assert_eq!(tool_calls[0].id, "toolu_1");
586 assert_eq!(tool_calls[0].function.name, "search");
587 }
588}