1use 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};
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 reasoning_signature: None,
33 content_parts: None,
34 image_ocr: None,
35 phase: None,
36 tool_calls: None, 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
62pub 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 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 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 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#[cfg(test)]
222pub struct AnthropicResponseConverter;
223
224#[cfg(test)]
225impl AnthropicResponseConverter {
226 pub fn convert_response(response: AnthropicMessagesResponse) -> ProtocolResult<Message> {
228 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
280fn 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 Role::System => AnthropicRole::User,
298 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 }
314 AnthropicContentBlock::ToolUse { .. } => {
315 }
317 AnthropicContentBlock::ToolResult { content, .. } => {
318 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#[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 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 let internal_schema: ToolSchema =
533 ToolSchema::from_provider(anthropic_tool.clone()).unwrap();
534 assert_eq!(internal_schema.function.name, "search");
535
536 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}