1use 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};
11pub struct AnthropicProtocol;
13
14impl 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, 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
61pub 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 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 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 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#[cfg(test)]
221pub struct AnthropicResponseConverter;
222
223#[cfg(test)]
224impl AnthropicResponseConverter {
225 pub fn convert_response(response: AnthropicMessagesResponse) -> ProtocolResult<Message> {
227 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
278fn 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 Role::System => AnthropicRole::User,
296 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 }
312 AnthropicContentBlock::ToolUse { .. } => {
313 }
315 AnthropicContentBlock::ToolResult { content, .. } => {
316 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#[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 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 let internal_schema: ToolSchema =
531 ToolSchema::from_provider(anthropic_tool.clone()).unwrap();
532 assert_eq!(internal_schema.function.name, "search");
533
534 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}