Skip to main content

bamboo_llm/protocol/
anthropic.rs

1//! Anthropic protocol conversion implementation.
2
3use 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};
12/// Anthropic protocol converter.
13pub struct AnthropicProtocol;
14
15// ============================================================================
16// Anthropic → Internal (FromProvider)
17// ============================================================================
18
19impl 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, // Anthropic messages don't have tool_calls at this level
38            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
63// ============================================================================
64// Internal → Anthropic (ToProvider)
65// ============================================================================
66
67/// Converts internal messages to Anthropic request format.
68///
69/// Note: Anthropic has a special structure where system messages are
70/// extracted to a top-level field, not included in the messages array.
71pub 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                // System messages are handled at the request level
112                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                // Add tool calls as tool_use blocks
146                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                // Tool messages become tool_result blocks wrapped in a user message
175                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// ============================================================================
204// Response conversion (for proxy/adapter use cases)
205// ============================================================================
206
207/// Convert Anthropic response to internal format (for API proxy scenarios)
208#[cfg(test)]
209pub struct AnthropicResponseConverter;
210
211#[cfg(test)]
212impl AnthropicResponseConverter {
213    /// Convert Anthropic messages response to internal message format
214    pub fn convert_response(response: AnthropicMessagesResponse) -> ProtocolResult<Message> {
215        // Extract text content from response blocks
216        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
267// ============================================================================
268// Helper functions
269// ============================================================================
270
271fn 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        // Note: System messages are handled specially in Anthropic
284        Role::System => AnthropicRole::User,
285        // Tool messages become user messages with tool_result blocks
286        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                // Keep `content` as text-only projection for text-only subsystems.
300            }
301            AnthropicContentBlock::ToolUse { .. } => {
302                // Tool calls are handled separately
303            }
304            AnthropicContentBlock::ToolResult { content, .. } => {
305                // Extract text from tool result
306                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// ============================================================================
380// Extension trait for ergonomic conversion
381// ============================================================================
382
383/// Extension trait for Anthropic conversion (test-only)
384#[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        // Already an Anthropic message, just clone it
398        // In practice, you'd deserialize and re-serialize
399        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        // Anthropic → Internal
519        let internal_schema: ToolSchema =
520            ToolSchema::from_provider(anthropic_tool.clone()).unwrap();
521        assert_eq!(internal_schema.function.name, "search");
522
523        // Internal → Anthropic
524        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}