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