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: crate::providers::common::tool_schema::canonicalize_json_value(
212                &self.function.parameters,
213            ),
214        })
215    }
216}
217
218// ============================================================================
219// Response conversion (for proxy/adapter use cases)
220// ============================================================================
221
222/// Convert Anthropic response to internal format (for API proxy scenarios)
223#[cfg(test)]
224pub struct AnthropicResponseConverter;
225
226#[cfg(test)]
227impl AnthropicResponseConverter {
228    /// Convert Anthropic messages response to internal message format
229    pub fn convert_response(response: AnthropicMessagesResponse) -> ProtocolResult<Message> {
230        // Extract text content from response blocks
231        let mut text_parts = Vec::new();
232        let mut tool_calls = Vec::new();
233
234        for block in response.content {
235            match block {
236                AnthropicResponseContentBlock::Text { text } => {
237                    text_parts.push(text);
238                }
239                AnthropicResponseContentBlock::ToolUse { id, name, input } => {
240                    tool_calls.push(ToolCall {
241                        id,
242                        tool_type: "function".to_string(),
243                        function: FunctionCall {
244                            name,
245                            arguments: serde_json::to_string(&input)
246                                .unwrap_or_else(|_| String::new()),
247                        },
248                    });
249                }
250            }
251        }
252
253        let content = text_parts.join("");
254        let tool_calls = if tool_calls.is_empty() {
255            None
256        } else {
257            Some(tool_calls)
258        };
259
260        Ok(Message {
261            id: response.id,
262            role: Role::Assistant,
263            content,
264            reasoning: None,
265            reasoning_signature: None,
266            content_parts: None,
267            image_ocr: None,
268            phase: None,
269            tool_calls,
270            tool_call_id: None,
271            tool_success: None,
272            compressed: false,
273            compressed_by_event_id: None,
274            never_compress: false,
275            compression_level: 0,
276            created_at: chrono::Utc::now(),
277            metadata: None,
278        })
279    }
280}
281
282// ============================================================================
283// Helper functions
284// ============================================================================
285
286fn convert_anthropic_role_to_internal(role: &AnthropicRole) -> Role {
287    match role {
288        AnthropicRole::User => Role::User,
289        AnthropicRole::Assistant => Role::Assistant,
290        AnthropicRole::System => Role::System,
291    }
292}
293
294fn convert_internal_role_to_anthropic(role: &Role) -> AnthropicRole {
295    match role {
296        Role::User => AnthropicRole::User,
297        Role::Assistant => AnthropicRole::Assistant,
298        // Note: System messages are handled specially in Anthropic
299        Role::System => AnthropicRole::User,
300        // Tool messages become user messages with tool_result blocks
301        Role::Tool => AnthropicRole::User,
302    }
303}
304
305fn extract_text_from_anthropic_blocks(
306    blocks: Vec<AnthropicContentBlock>,
307) -> ProtocolResult<String> {
308    let mut texts = Vec::new();
309
310    for block in blocks {
311        match block {
312            AnthropicContentBlock::Text { text } => texts.push(text),
313            AnthropicContentBlock::Image { .. } => {
314                // Keep `content` as text-only projection for text-only subsystems.
315            }
316            AnthropicContentBlock::ToolUse { .. } => {
317                // Tool calls are handled separately
318            }
319            AnthropicContentBlock::ToolResult { content, .. } => {
320                // Extract text from tool result
321                match content {
322                    Value::String(s) => texts.push(s),
323                    Value::Array(arr) => {
324                        for item in arr {
325                            if let Some(obj) = item.as_object() {
326                                if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
327                                    texts.push(text.to_string());
328                                }
329                            }
330                        }
331                    }
332                    _ => {}
333                }
334            }
335        }
336    }
337
338    Ok(texts.join("\n"))
339}
340
341fn content_part_to_anthropic_block(
342    part: &bamboo_domain::MessagePart,
343) -> Option<AnthropicContentBlock> {
344    match part {
345        bamboo_domain::MessagePart::Text { text } => {
346            Some(AnthropicContentBlock::Text { text: text.clone() })
347        }
348        bamboo_domain::MessagePart::ImageUrl { image_url } => {
349            let trimmed = image_url.url.trim();
350            if trimmed.is_empty() {
351                return None;
352            }
353            if let Some((media_type, data)) = parse_data_url_base64(trimmed) {
354                return Some(AnthropicContentBlock::Image {
355                    source: AnthropicImageSource::Base64 { media_type, data },
356                });
357            }
358            Some(AnthropicContentBlock::Image {
359                source: AnthropicImageSource::Url {
360                    url: trimmed.to_string(),
361                },
362            })
363        }
364    }
365}
366
367fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
368    let rest = url.strip_prefix("data:")?;
369    let (meta, data) = rest.split_once(',')?;
370    let data = data.trim();
371    if data.is_empty() {
372        return None;
373    }
374
375    let mut media_type = "application/octet-stream";
376    let mut is_base64 = false;
377    for (idx, seg) in meta.split(';').enumerate() {
378        let segment = seg.trim();
379        if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
380            media_type = segment;
381        }
382        if segment.eq_ignore_ascii_case("base64") {
383            is_base64 = true;
384        }
385    }
386
387    if !is_base64 {
388        return None;
389    }
390
391    Some((media_type.to_string(), data.to_string()))
392}
393
394// ============================================================================
395// Extension trait for ergonomic conversion
396// ============================================================================
397
398/// Extension trait for Anthropic conversion (test-only)
399#[cfg(test)]
400pub trait AnthropicExt: Sized {
401    fn into_internal(self) -> ProtocolResult<Message>;
402    fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage>;
403}
404
405#[cfg(test)]
406impl AnthropicExt for AnthropicMessage {
407    fn into_internal(self) -> ProtocolResult<Message> {
408        Message::from_provider(self)
409    }
410
411    fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage> {
412        // Already an Anthropic message, just clone it
413        // In practice, you'd deserialize and re-serialize
414        unimplemented!("Use clone for now")
415    }
416}
417
418#[cfg(test)]
419impl AnthropicExt for Message {
420    fn into_internal(self) -> ProtocolResult<Message> {
421        Ok(self)
422    }
423
424    fn to_anthropic(&self) -> ProtocolResult<AnthropicMessage> {
425        self.to_provider()
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_anthropic_to_internal_text_message() {
435        let anthropic_msg = AnthropicMessage {
436            role: AnthropicRole::User,
437            content: AnthropicContent::Text("Hello".to_string()),
438        };
439
440        let internal: Message = anthropic_msg.into_internal().unwrap();
441
442        assert_eq!(internal.role, Role::User);
443        assert_eq!(internal.content, "Hello");
444    }
445
446    #[test]
447    fn test_internal_to_anthropic_user_message() {
448        let internal = Message::user("Hello");
449
450        let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
451
452        assert_eq!(anthropic.role, AnthropicRole::User);
453        match anthropic.content {
454            AnthropicContent::Blocks(blocks) => {
455                assert_eq!(blocks.len(), 1);
456                assert!(
457                    matches!(blocks[0], AnthropicContentBlock::Text { text: ref t } if t == "Hello")
458                );
459            }
460            _ => panic!("Expected Blocks content"),
461        }
462    }
463
464    #[test]
465    fn test_internal_to_anthropic_system_message_extraction() {
466        let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
467
468        let request: AnthropicRequest = messages.to_provider().unwrap();
469
470        assert_eq!(request.system, Some("You are helpful".to_string()));
471        assert_eq!(request.messages.len(), 1);
472        assert_eq!(request.messages[0].role, AnthropicRole::User);
473    }
474
475    #[test]
476    fn test_internal_to_anthropic_with_tool_call() {
477        let tool_call = ToolCall {
478            id: "toolu_1".to_string(),
479            tool_type: "function".to_string(),
480            function: FunctionCall {
481                name: "search".to_string(),
482                arguments: r#"{"q":"test"}"#.to_string(),
483            },
484        };
485
486        let internal = Message::assistant("Let me search", Some(vec![tool_call]));
487
488        let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
489
490        match anthropic.content {
491            AnthropicContent::Blocks(blocks) => {
492                assert_eq!(blocks.len(), 2);
493                assert!(matches!(blocks[0], AnthropicContentBlock::Text { .. }));
494                assert!(
495                    matches!(blocks[1], AnthropicContentBlock::ToolUse { ref id, ref name, .. } if id == "toolu_1" && name == "search")
496                );
497            }
498            _ => panic!("Expected Blocks content"),
499        }
500    }
501
502    #[test]
503    fn test_tool_message_to_anthropic() {
504        let internal = Message::tool_result("toolu_1", "Result here");
505
506        let anthropic: AnthropicMessage = internal.to_anthropic().unwrap();
507
508        assert_eq!(anthropic.role, AnthropicRole::User);
509        match anthropic.content {
510            AnthropicContent::Blocks(blocks) => {
511                assert_eq!(blocks.len(), 1);
512                assert!(
513                    matches!(blocks[0], AnthropicContentBlock::ToolResult { ref tool_use_id, .. } if tool_use_id == "toolu_1")
514                );
515            }
516            _ => panic!("Expected Blocks content"),
517        }
518    }
519
520    #[test]
521    fn test_tool_schema_conversion() {
522        let anthropic_tool = AnthropicTool {
523            name: "search".to_string(),
524            description: Some("Search the web".to_string()),
525            input_schema: serde_json::json!({
526                "type": "object",
527                "properties": {
528                    "q": { "type": "string" }
529                }
530            }),
531        };
532
533        // Anthropic → Internal
534        let internal_schema: ToolSchema =
535            ToolSchema::from_provider(anthropic_tool.clone()).unwrap();
536        assert_eq!(internal_schema.function.name, "search");
537
538        // Internal → Anthropic
539        let roundtrip: AnthropicTool = internal_schema.to_provider().unwrap();
540        assert_eq!(roundtrip.name, "search");
541        assert_eq!(roundtrip.description, Some("Search the web".to_string()));
542    }
543
544    #[test]
545    fn test_anthropic_response_to_internal() {
546        let response = AnthropicMessagesResponse {
547            id: "msg_1".to_string(),
548            response_type: "message".to_string(),
549            role: "assistant".to_string(),
550            content: vec![AnthropicResponseContentBlock::Text {
551                text: "Hello, world!".to_string(),
552            }],
553            model: "claude-3-sonnet".to_string(),
554            stop_reason: "end_turn".to_string(),
555            stop_sequence: None,
556            usage: AnthropicUsage {
557                input_tokens: 10,
558                output_tokens: 5,
559            },
560        };
561
562        let internal = AnthropicResponseConverter::convert_response(response).unwrap();
563
564        assert_eq!(internal.role, Role::Assistant);
565        assert_eq!(internal.content, "Hello, world!");
566        assert!(internal.tool_calls.is_none());
567    }
568
569    #[test]
570    fn test_anthropic_response_with_tool_use() {
571        let response = AnthropicMessagesResponse {
572            id: "msg_1".to_string(),
573            response_type: "message".to_string(),
574            role: "assistant".to_string(),
575            content: vec![
576                AnthropicResponseContentBlock::Text {
577                    text: "Let me help you search.".to_string(),
578                },
579                AnthropicResponseContentBlock::ToolUse {
580                    id: "toolu_1".to_string(),
581                    name: "search".to_string(),
582                    input: serde_json::json!({"q": "test"}),
583                },
584            ],
585            model: "claude-3-sonnet".to_string(),
586            stop_reason: "tool_use".to_string(),
587            stop_sequence: None,
588            usage: AnthropicUsage {
589                input_tokens: 10,
590                output_tokens: 5,
591            },
592        };
593
594        let internal = AnthropicResponseConverter::convert_response(response).unwrap();
595
596        assert_eq!(internal.content, "Let me help you search.");
597        assert!(internal.tool_calls.is_some());
598        let tool_calls = internal.tool_calls.unwrap();
599        assert_eq!(tool_calls.len(), 1);
600        assert_eq!(tool_calls[0].id, "toolu_1");
601        assert_eq!(tool_calls[0].function.name, "search");
602    }
603}