Skip to main content

bamboo_infrastructure/llm/protocol/
anthropic.rs

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