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: crate::providers::common::tool_schema::canonicalize_json_value(
212 &self.function.parameters,
213 ),
214 })
215 }
216}
217
218#[cfg(test)]
224pub struct AnthropicResponseConverter;
225
226#[cfg(test)]
227impl AnthropicResponseConverter {
228 pub fn convert_response(response: AnthropicMessagesResponse) -> ProtocolResult<Message> {
230 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
282fn 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 Role::System => AnthropicRole::User,
300 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 }
316 AnthropicContentBlock::ToolUse { .. } => {
317 }
319 AnthropicContentBlock::ToolResult { content, .. } => {
320 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#[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 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 let internal_schema: ToolSchema =
535 ToolSchema::from_provider(anthropic_tool.clone()).unwrap();
536 assert_eq!(internal_schema.function.name, "search");
537
538 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}