1use serde_json::Value as JsonValue;
27
28use crate::{
29 Api, AssistantMessage, ContentBlock, ImageContent, ImageContentType, InputModality, Message,
30 MessageContent, Model, StopReason, TextContent, TextContentType, ThinkingContent, ToolCall,
31 ToolCallType, ToolResultMessage, Usage,
32};
33
34#[derive(Debug, Clone)]
40pub struct TransformOptions {
41 pub strip_thinking: bool,
43 pub convert_tools: bool,
45 pub convert_images: bool,
47 pub merge_text: bool,
49}
50
51impl Default for TransformOptions {
52 fn default() -> Self {
53 Self {
54 strip_thinking: false,
55 convert_tools: true,
56 convert_images: true,
57 merge_text: true,
58 }
59 }
60}
61
62pub fn transform_messages(
87 messages: &[Message],
88 from_api: Api,
89 to_api: Api,
90 opts: TransformOptions,
91) -> Vec<Message> {
92 if from_api == to_api {
93 return messages.to_vec();
94 }
95
96 let intermediate: Vec<IntermediateMessage> = messages
99 .iter()
100 .map(|m| to_intermediate(m, &from_api))
101 .collect();
102
103 intermediate
104 .into_iter()
105 .map(|im| from_intermediate(&im, &to_api, &opts))
106 .collect()
107}
108
109#[derive(Debug, Clone)]
116enum IntermediateMessage {
117 User {
118 content: IntermediateContent,
119 },
120 Assistant {
121 content: Vec<IntermediateBlock>,
122 model: String,
123 provider: String,
124 usage: Usage,
125 stop_reason: StopReason,
126 error_message: Option<String>,
127 response_id: Option<String>,
128 timestamp: i64,
129 },
130 ToolResult {
131 tool_call_id: String,
132 tool_name: String,
133 content: Vec<IntermediateBlock>,
134 is_error: bool,
135 },
136}
137
138#[derive(Debug, Clone)]
140enum IntermediateContent {
141 Text(String),
142 Blocks(Vec<IntermediateBlock>),
143}
144
145#[derive(Debug, Clone)]
147enum IntermediateBlock {
148 Text(String),
149 Thinking {
150 text: String,
151 signature: Option<String>,
152 },
153 Image {
154 data: String,
155 mime_type: String,
156 },
157 ToolCall {
158 id: String,
159 name: String,
160 arguments: JsonValue,
161 },
162}
163
164fn to_intermediate(msg: &Message, _from_api: &Api) -> IntermediateMessage {
170 match msg {
171 Message::User(u) => {
172 let content = match &u.content {
173 MessageContent::Text(s) => IntermediateContent::Text(s.clone()),
174 MessageContent::Blocks(blocks) => {
175 IntermediateContent::Blocks(blocks.iter().map(block_to_intermediate).collect())
176 }
177 };
178 IntermediateMessage::User { content }
179 }
180
181 Message::Assistant(a) => IntermediateMessage::Assistant {
182 content: a.content.iter().map(block_to_intermediate).collect(),
183 model: a.model.clone(),
184 provider: a.provider.clone(),
185 usage: a.usage.clone(),
186 stop_reason: a.stop_reason,
187 error_message: a.error_message.clone(),
188 response_id: a.response_id.clone(),
189 timestamp: a.timestamp,
190 },
191
192 Message::ToolResult(t) => IntermediateMessage::ToolResult {
193 tool_call_id: t.tool_call_id.clone(),
194 tool_name: t.tool_name.clone(),
195 content: t.content.iter().map(block_to_intermediate).collect(),
196 is_error: t.is_error,
197 },
198 }
199}
200
201fn block_to_intermediate(block: &ContentBlock) -> IntermediateBlock {
202 match block {
203 ContentBlock::Text(t) => IntermediateBlock::Text(t.text.clone()),
204 ContentBlock::Thinking(th) => IntermediateBlock::Thinking {
205 text: th.thinking.clone(),
206 signature: th.thinking_signature.clone(),
207 },
208 ContentBlock::Image(img) => IntermediateBlock::Image {
209 data: img.data.clone(),
210 mime_type: img.mime_type.clone(),
211 },
212 ContentBlock::ToolCall(tc) => IntermediateBlock::ToolCall {
213 id: tc.id.clone(),
214 name: tc.name.clone(),
215 arguments: tc.arguments.clone(),
216 },
217 ContentBlock::Unknown(val) => {
218 if let Some(text) = val.get("text").and_then(|v| v.as_str()) {
220 IntermediateBlock::Text(text.to_string())
221 } else {
222 IntermediateBlock::Text(format!("[unknown block: {}]", val))
223 }
224 }
225 }
226}
227
228fn from_intermediate(im: &IntermediateMessage, to_api: &Api, opts: &TransformOptions) -> Message {
234 match im {
235 IntermediateMessage::User { content } => {
236 let native_content = match content {
237 IntermediateContent::Text(s) => MessageContent::Text(s.clone()),
238 IntermediateContent::Blocks(blocks) => {
239 let native_blocks: Vec<ContentBlock> = blocks
240 .iter()
241 .flat_map(|b| intermediate_to_blocks(b, to_api, opts))
242 .collect();
243 let merged = if opts.merge_text {
244 merge_adjacent_text_blocks(native_blocks)
245 } else {
246 native_blocks
247 };
248 MessageContent::Blocks(merged)
249 }
250 };
251 Message::User(crate::UserMessage {
252 role: crate::UserRole::User,
253 content: native_content,
254 timestamp: chrono::Utc::now().timestamp_millis(),
255 visible: true,
256 })
257 }
258
259 IntermediateMessage::Assistant {
260 content,
261 model,
262 provider,
263 usage,
264 stop_reason,
265 error_message,
266 response_id,
267 timestamp,
268 } => {
269 let mut native_blocks: Vec<ContentBlock> = content
270 .iter()
271 .flat_map(|b| intermediate_to_blocks(b, to_api, opts))
272 .collect();
273 if opts.merge_text {
274 native_blocks = merge_adjacent_text_blocks(native_blocks);
275 }
276
277 let mut msg = AssistantMessage::new(*to_api, provider, model);
278 msg.content = native_blocks;
279 msg.usage = usage.clone();
280 msg.stop_reason = *stop_reason;
281 msg.error_message = error_message.clone();
282 msg.response_id = response_id.clone();
283 msg.timestamp = *timestamp;
284 Message::Assistant(msg)
285 }
286
287 IntermediateMessage::ToolResult {
288 tool_call_id,
289 tool_name,
290 content,
291 is_error,
292 } => {
293 let mut native_blocks: Vec<ContentBlock> = content
294 .iter()
295 .flat_map(|b| intermediate_to_blocks(b, to_api, opts))
296 .collect();
297 if opts.merge_text {
298 native_blocks = merge_adjacent_text_blocks(native_blocks);
299 }
300
301 let mut msg = ToolResultMessage::new(tool_call_id, tool_name, native_blocks);
302 msg.is_error = *is_error;
303 Message::ToolResult(msg)
304 }
305 }
306}
307
308fn intermediate_to_blocks(
311 ib: &IntermediateBlock,
312 to_api: &Api,
313 opts: &TransformOptions,
314) -> Vec<ContentBlock> {
315 match ib {
316 IntermediateBlock::Text(text) => {
317 vec![ContentBlock::Text(TextContent {
318 content_type: TextContentType::Text,
319 text: text.clone(),
320 text_signature: None,
321 })]
322 }
323
324 IntermediateBlock::Thinking { text, signature } => {
325 if opts.strip_thinking {
326 return vec![];
327 }
328 match to_api {
329 Api::AnthropicMessages => {
331 let th = ThinkingContent {
332 content_type: crate::ThinkingContentType::Thinking,
333 thinking: text.clone(),
334 thinking_signature: signature.clone(),
335 redacted: None,
336 };
337 vec![ContentBlock::Thinking(th)]
338 }
339 _ => {
341 let wrapped = format!("<thinking>\n{}\n</thinking>", text);
342 vec![ContentBlock::Text(TextContent {
343 content_type: TextContentType::Text,
344 text: wrapped,
345 text_signature: None,
346 })]
347 }
348 }
349 }
350
351 IntermediateBlock::Image { data, mime_type } => {
352 if !opts.convert_images {
353 return vec![];
354 }
355 vec![ContentBlock::Image(ImageContent {
356 content_type: ImageContentType::Image,
357 data: data.clone(),
358 mime_type: mime_type.clone(),
359 })]
360 }
361
362 IntermediateBlock::ToolCall {
363 id,
364 name,
365 arguments,
366 } => {
367 if !opts.convert_tools {
368 return vec![];
369 }
370 vec![ContentBlock::ToolCall(ToolCall {
371 content_type: ToolCallType::ToolCall,
372 id: id.clone(),
373 name: name.clone(),
374 arguments: arguments.clone(),
375 thought_signature: None,
376 })]
377 }
378 }
379}
380
381fn merge_adjacent_text_blocks(blocks: Vec<ContentBlock>) -> Vec<ContentBlock> {
387 let mut result = Vec::with_capacity(blocks.len());
388 let estimated_len = blocks
389 .iter()
390 .map(|b| match b {
391 ContentBlock::Text(t) => t.text.len() + 1,
392 _ => 0,
393 })
394 .sum::<usize>();
395 let mut pending = String::with_capacity(estimated_len.max(256));
396
397 for block in blocks {
398 match block {
399 ContentBlock::Text(t) => {
400 if !pending.is_empty() {
401 pending.push('\n');
402 }
403 pending.push_str(&t.text);
404 }
405 other => {
406 if !pending.is_empty() {
407 result.push(ContentBlock::Text(TextContent {
408 content_type: TextContentType::Text,
409 text: std::mem::take(&mut pending),
410 text_signature: None,
411 }));
412 }
413 result.push(other);
414 }
415 }
416 }
417
418 if !pending.is_empty() {
419 result.push(ContentBlock::Text(TextContent {
420 content_type: TextContentType::Text,
421 text: pending,
422 text_signature: None,
423 }));
424 }
425
426 result
427}
428
429const NON_VISION_USER_IMAGE_PLACEHOLDER: &str = "(image omitted: model does not support images)";
434const NON_VISION_TOOL_IMAGE_PLACEHOLDER: &str =
435 "(tool image omitted: model does not support images)";
436
437fn replace_images_with_placeholder(
440 blocks: &[ContentBlock],
441 placeholder: &str,
442) -> Vec<ContentBlock> {
443 let mut result = Vec::with_capacity(blocks.len());
444 let mut prev_was_placeholder = false;
445
446 for block in blocks {
447 if matches!(block, ContentBlock::Image(_)) {
448 if !prev_was_placeholder {
449 result.push(ContentBlock::Text(TextContent::new(placeholder)));
450 }
451 prev_was_placeholder = true;
452 continue;
453 }
454
455 result.push(block.clone());
456 prev_was_placeholder = matches!(block, ContentBlock::Text(t) if t.text == placeholder);
457 }
458
459 result
460}
461
462fn downgrade_unsupported_images(messages: &[Message], model: &Model) -> Vec<Message> {
464 if model.input.contains(&InputModality::Image) {
465 return messages.to_vec();
466 }
467
468 messages
469 .iter()
470 .map(|msg| match msg {
471 Message::User(u) => match &u.content {
472 MessageContent::Blocks(blocks) => {
473 let replaced =
474 replace_images_with_placeholder(blocks, NON_VISION_USER_IMAGE_PLACEHOLDER);
475 Message::User(crate::UserMessage {
476 role: u.role,
477 content: MessageContent::Blocks(replaced),
478 timestamp: u.timestamp,
479 visible: true,
480 })
481 }
482 _ => msg.clone(),
483 },
484 Message::ToolResult(t) => {
485 let replaced =
486 replace_images_with_placeholder(&t.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER);
487 Message::ToolResult(ToolResultMessage {
488 role: t.role,
489 tool_call_id: t.tool_call_id.clone(),
490 tool_name: t.tool_name.clone(),
491 content: replaced,
492 details: t.details.clone(),
493 is_error: t.is_error,
494 timestamp: t.timestamp,
495 })
496 }
497 _ => msg.clone(),
498 })
499 .collect()
500}
501
502pub fn normalize_tool_call_id(id: &str) -> String {
506 crate::utils::normalize_tool_call_id(id)
507}
508
509pub fn transform_messages_for_model(messages: &[Message], model: &Model) -> Vec<Message> {
520 let image_aware = downgrade_unsupported_images(messages, model);
521
522 let mut tool_call_id_map: std::collections::HashMap<String, String> =
524 std::collections::HashMap::new();
525
526 let transformed: Vec<Message> = image_aware
528 .iter()
529 .map(|msg| match msg {
530 Message::User(_) => msg.clone(),
531
532 Message::ToolResult(t) => {
533 if let Some(normalized) = tool_call_id_map.get(&t.tool_call_id)
534 && normalized != &t.tool_call_id
535 {
536 return Message::ToolResult(ToolResultMessage {
537 tool_call_id: normalized.clone(),
538 ..t.clone()
539 });
540 }
541 msg.clone()
542 }
543
544 Message::Assistant(a) => {
545 let is_same_model =
546 a.provider == model.provider && a.api == model.api && a.model == model.id;
547
548 let new_content: Vec<ContentBlock> = a
549 .content
550 .iter()
551 .flat_map(|block| match block {
552 ContentBlock::Thinking(th) => {
553 if th.redacted == Some(true) && !is_same_model {
555 return vec![];
556 }
557 if is_same_model && th.thinking_signature.is_some() {
559 return vec![block.clone()];
560 }
561 if th.thinking.trim().is_empty() {
563 return vec![];
564 }
565 if is_same_model {
567 return vec![block.clone()];
568 }
569 vec![ContentBlock::Text(TextContent::new(&th.thinking))]
571 }
572
573 ContentBlock::Text(_) => vec![block.clone()],
574
575 ContentBlock::ToolCall(tc) => {
576 let mut new_tc = tc.clone();
577
578 if !is_same_model && tc.thought_signature.is_some() {
580 new_tc.thought_signature = None;
581 }
582
583 if !is_same_model {
585 let normalized = normalize_tool_call_id(&tc.id);
586 if normalized != tc.id {
587 tool_call_id_map.insert(tc.id.clone(), normalized.clone());
588 new_tc.id = normalized;
589 }
590 }
591
592 vec![ContentBlock::ToolCall(new_tc)]
593 }
594
595 _ => vec![block.clone()],
596 })
597 .collect();
598
599 Message::Assistant(AssistantMessage {
600 content: new_content,
601 ..a.clone()
602 })
603 }
604 })
605 .collect();
606
607 let mut result: Vec<Message> = Vec::with_capacity(transformed.len());
610 let mut pending_tool_calls: Vec<ToolCall> = Vec::new();
611 let mut existing_tool_result_ids: std::collections::HashSet<String> =
612 std::collections::HashSet::new();
613
614 let insert_synthetic_results = |pending: &mut Vec<ToolCall>,
615 existing: &mut std::collections::HashSet<String>,
616 out: &mut Vec<Message>| {
617 for tc in pending.drain(..) {
618 if !existing.contains(&tc.id) {
619 out.push(Message::ToolResult(ToolResultMessage {
620 role: crate::ToolResultRole::ToolResult,
621 tool_call_id: tc.id.clone(),
622 tool_name: tc.name.clone(),
623 content: vec![ContentBlock::Text(TextContent::new("No result provided"))],
624 details: None,
625 is_error: true,
626 timestamp: chrono::Utc::now().timestamp_millis(),
627 }));
628 }
629 }
630 existing.clear();
631 };
632
633 for msg in &transformed {
634 match msg {
635 Message::Assistant(a) => {
636 insert_synthetic_results(
638 &mut pending_tool_calls,
639 &mut existing_tool_result_ids,
640 &mut result,
641 );
642
643 if a.stop_reason == StopReason::Error || a.stop_reason == StopReason::Aborted {
645 continue;
646 }
647
648 let tool_calls: Vec<&ToolCall> =
650 a.content.iter().filter_map(|b| b.as_tool_call()).collect();
651 if !tool_calls.is_empty() {
652 pending_tool_calls = tool_calls.into_iter().cloned().collect();
653 existing_tool_result_ids.clear();
654 }
655
656 result.push(msg.clone());
657 }
658
659 Message::ToolResult(t) => {
660 existing_tool_result_ids.insert(t.tool_call_id.clone());
661 result.push(msg.clone());
662 }
663
664 Message::User(_) => {
665 insert_synthetic_results(
667 &mut pending_tool_calls,
668 &mut existing_tool_result_ids,
669 &mut result,
670 );
671 result.push(msg.clone());
672 }
673 }
674 }
675
676 insert_synthetic_results(
678 &mut pending_tool_calls,
679 &mut existing_tool_result_ids,
680 &mut result,
681 );
682
683 result
684}
685
686pub fn anthropic_to_openai(messages: &[Message]) -> Vec<Message> {
692 transform_messages(
693 messages,
694 Api::AnthropicMessages,
695 Api::OpenAiCompletions,
696 TransformOptions::default(),
697 )
698}
699
700pub fn openai_to_anthropic(messages: &[Message]) -> Vec<Message> {
702 transform_messages(
703 messages,
704 Api::OpenAiCompletions,
705 Api::AnthropicMessages,
706 TransformOptions::default(),
707 )
708}
709
710pub fn google_to_openai(messages: &[Message]) -> Vec<Message> {
712 transform_messages(
713 messages,
714 Api::GoogleGenerativeAi,
715 Api::OpenAiCompletions,
716 TransformOptions::default(),
717 )
718}
719
720pub fn anthropic_to_google(messages: &[Message]) -> Vec<Message> {
722 transform_messages(
723 messages,
724 Api::AnthropicMessages,
725 Api::GoogleGenerativeAi,
726 TransformOptions::default(),
727 )
728}
729
730#[cfg(test)]
735mod tests {
736 use super::*;
737 use crate::UserMessage;
738
739 fn user_msg(text: &str) -> Message {
741 Message::User(UserMessage::new(text))
742 }
743
744 fn assistant_msg(api: Api, provider: &str, model: &str, blocks: Vec<ContentBlock>) -> Message {
746 let mut msg = AssistantMessage::new(api, provider, model);
747 msg.content = blocks;
748 Message::Assistant(msg)
749 }
750
751 fn tool_result_msg(tool_call_id: &str, tool_name: &str, text: &str) -> Message {
753 Message::ToolResult(ToolResultMessage::new(
754 tool_call_id,
755 tool_name,
756 vec![ContentBlock::Text(TextContent::new(text))],
757 ))
758 }
759
760 #[test]
763 fn test_anthropic_to_openai_text() {
764 let msgs = vec![
765 user_msg("Hello"),
766 assistant_msg(
767 Api::AnthropicMessages,
768 "anthropic",
769 "claude-3.5-sonnet",
770 vec![ContentBlock::Text(TextContent::new("Hi there!"))],
771 ),
772 ];
773
774 let result = anthropic_to_openai(&msgs);
775 assert_eq!(result.len(), 2);
776
777 match &result[0] {
779 Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
780 _ => panic!("Expected User message"),
781 }
782
783 match &result[1] {
785 Message::Assistant(a) => {
786 assert_eq!(a.api, Api::OpenAiCompletions);
787 assert_eq!(a.text_content(), "Hi there!");
788 }
789 _ => panic!("Expected Assistant message"),
790 }
791 }
792
793 #[test]
796 fn test_thinking_block_anthropic_to_openai() {
797 let msgs = vec![assistant_msg(
798 Api::AnthropicMessages,
799 "anthropic",
800 "claude-3.5-sonnet",
801 vec![
802 ContentBlock::Thinking(ThinkingContent::new("Let me think...")),
803 ContentBlock::Text(TextContent::new("Here's the answer.")),
804 ],
805 )];
806
807 let result = anthropic_to_openai(&msgs);
808 match &result[0] {
809 Message::Assistant(a) => {
810 let text = a.text_content();
812 assert!(text.contains("<thinking>"));
813 assert!(text.contains("Let me think..."));
814 assert!(text.contains("Here's the answer."));
815 assert!(
817 !a.content
818 .iter()
819 .any(|b| matches!(b, ContentBlock::Thinking(_)))
820 );
821 }
822 _ => panic!("Expected Assistant"),
823 }
824 }
825
826 #[test]
829 fn test_thinking_block_stripped() {
830 let msgs = vec![assistant_msg(
831 Api::AnthropicMessages,
832 "anthropic",
833 "claude-3.5-sonnet",
834 vec![
835 ContentBlock::Thinking(ThinkingContent::new("Internal thought")),
836 ContentBlock::Text(TextContent::new("Final answer.")),
837 ],
838 )];
839
840 let opts = TransformOptions {
841 strip_thinking: true,
842 ..Default::default()
843 };
844 let result =
845 transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
846
847 match &result[0] {
848 Message::Assistant(a) => {
849 assert_eq!(a.content.len(), 1);
851 assert_eq!(a.text_content(), "Final answer.");
852 }
853 _ => panic!("Expected Assistant"),
854 }
855 }
856
857 #[test]
860 fn test_tool_calls_preserved() {
861 let tool_call = ContentBlock::ToolCall(ToolCall::new(
862 "call_123",
863 "get_weather",
864 serde_json::json!({"city": "Tokyo"}),
865 ));
866
867 let msgs = vec![
868 assistant_msg(
869 Api::AnthropicMessages,
870 "anthropic",
871 "claude-3.5-sonnet",
872 vec![
873 ContentBlock::Text(TextContent::new("Let me check.")),
874 tool_call,
875 ],
876 ),
877 tool_result_msg("call_123", "get_weather", "Sunny, 22°C"),
878 ];
879
880 let result = anthropic_to_openai(&msgs);
881
882 match &result[0] {
884 Message::Assistant(a) => {
885 let tc = a.content.iter().find_map(|b| b.as_tool_call());
886 assert!(tc.is_some(), "Tool call should be preserved");
887 let tc = tc.unwrap();
888 assert_eq!(tc.id, "call_123");
889 assert_eq!(tc.name, "get_weather");
890 }
891 _ => panic!("Expected Assistant"),
892 }
893
894 match &result[1] {
896 Message::ToolResult(t) => {
897 assert_eq!(t.tool_call_id, "call_123");
898 assert_eq!(t.tool_name, "get_weather");
899 }
900 _ => panic!("Expected ToolResult"),
901 }
902 }
903
904 #[test]
907 fn test_tool_calls_dropped_with_option() {
908 let msgs = vec![assistant_msg(
909 Api::AnthropicMessages,
910 "anthropic",
911 "claude-3.5-sonnet",
912 vec![
913 ContentBlock::Text(TextContent::new("I will call a tool.")),
914 ContentBlock::ToolCall(ToolCall::new("tc_1", "search", serde_json::json!({}))),
915 ],
916 )];
917
918 let opts = TransformOptions {
919 convert_tools: false,
920 ..Default::default()
921 };
922 let result =
923 transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
924
925 match &result[0] {
926 Message::Assistant(a) => {
927 assert_eq!(a.content.len(), 1);
928 assert_eq!(a.text_content(), "I will call a tool.");
929 }
930 _ => panic!("Expected Assistant"),
931 }
932 }
933
934 #[test]
937 fn test_image_block_conversion() {
938 let msgs = vec![assistant_msg(
939 Api::AnthropicMessages,
940 "anthropic",
941 "claude-3.5-sonnet",
942 vec![
943 ContentBlock::Text(TextContent::new("Here's the image:")),
944 ContentBlock::Image(ImageContent::new("iVBORw0KGgo=", "image/png")),
945 ],
946 )];
947
948 let result = anthropic_to_openai(&msgs);
949
950 match &result[0] {
951 Message::Assistant(a) => {
952 let has_text = a.content.iter().any(|b| matches!(b, ContentBlock::Text(_)));
953 let has_image = a
954 .content
955 .iter()
956 .any(|b| matches!(b, ContentBlock::Image(_)));
957 assert!(has_text, "Text block should be preserved");
958 assert!(has_image, "Image block should be preserved");
959 }
960 _ => panic!("Expected Assistant"),
961 }
962 }
963
964 #[test]
967 fn test_openai_to_anthropic_roundtrip() {
968 let original = vec![
969 user_msg("What is 2+2?"),
970 assistant_msg(
971 Api::OpenAiCompletions,
972 "openai",
973 "gpt-4o",
974 vec![ContentBlock::Text(TextContent::new("The answer is 4."))],
975 ),
976 ];
977
978 let to_anthropic = openai_to_anthropic(&original);
979 let back_to_openai = anthropic_to_openai(&to_anthropic);
980
981 match (&original[1], &back_to_openai[1]) {
983 (Message::Assistant(orig), Message::Assistant(rt)) => {
984 assert_eq!(orig.text_content(), rt.text_content());
985 }
986 _ => panic!("Expected Assistant messages"),
987 }
988 }
989
990 #[test]
993 fn test_google_to_openai() {
994 let msgs = vec![
995 user_msg("Summarize this"),
996 assistant_msg(
997 Api::GoogleGenerativeAi,
998 "google",
999 "gemini-2.0-flash",
1000 vec![ContentBlock::Text(TextContent::new("Here's a summary."))],
1001 ),
1002 ];
1003
1004 let result = google_to_openai(&msgs);
1005 assert_eq!(result.len(), 2);
1006
1007 match &result[1] {
1008 Message::Assistant(a) => {
1009 assert_eq!(a.api, Api::OpenAiCompletions);
1010 assert_eq!(a.text_content(), "Here's a summary.");
1011 }
1012 _ => panic!("Expected Assistant"),
1013 }
1014 }
1015
1016 #[test]
1019 fn test_same_api_noop() {
1020 let msgs = vec![user_msg("Hello")];
1021 let result = transform_messages(
1022 &msgs,
1023 Api::AnthropicMessages,
1024 Api::AnthropicMessages,
1025 TransformOptions::default(),
1026 );
1027 assert_eq!(result.len(), 1);
1028 match &result[0] {
1029 Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
1030 _ => panic!("Expected User"),
1031 }
1032 }
1033
1034 #[test]
1037 fn test_thinking_preserved_for_anthropic_target() {
1038 let msgs = vec![assistant_msg(
1039 Api::OpenAiCompletions,
1040 "openai",
1041 "gpt-4o",
1042 vec![
1043 ContentBlock::Thinking(ThinkingContent::new("Reasoning...")),
1044 ContentBlock::Text(TextContent::new("Answer.")),
1045 ],
1046 )];
1047
1048 let result = openai_to_anthropic(&msgs);
1049
1050 match &result[0] {
1051 Message::Assistant(a) => {
1052 let has_thinking = a
1054 .content
1055 .iter()
1056 .any(|b| matches!(b, ContentBlock::Thinking(_)));
1057 assert!(
1058 has_thinking,
1059 "Thinking block should be preserved for Anthropic"
1060 );
1061 }
1062 _ => panic!("Expected Assistant"),
1063 }
1064 }
1065
1066 #[test]
1069 fn test_anthropic_to_google_thinking() {
1070 let msgs = vec![assistant_msg(
1071 Api::AnthropicMessages,
1072 "anthropic",
1073 "claude-3.5-sonnet",
1074 vec![
1075 ContentBlock::Thinking(ThinkingContent::new("Deep thought")),
1076 ContentBlock::Text(TextContent::new("Result.")),
1077 ],
1078 )];
1079
1080 let result = anthropic_to_google(&msgs);
1081
1082 match &result[0] {
1083 Message::Assistant(a) => {
1084 let has_thinking = a
1086 .content
1087 .iter()
1088 .any(|b| matches!(b, ContentBlock::Thinking(_)));
1089 assert!(
1090 !has_thinking,
1091 "Google target should not have thinking blocks"
1092 );
1093 let text = a.text_content();
1095 assert!(text.contains("<thinking>"));
1096 assert!(text.contains("Deep thought"));
1097 assert!(text.contains("Result."));
1098 }
1099 _ => panic!("Expected Assistant"),
1100 }
1101 }
1102
1103 #[test]
1106 fn test_full_conversation_mixed_blocks() {
1107 let msgs = vec![
1108 user_msg("What's the weather in Paris?"),
1109 assistant_msg(
1110 Api::AnthropicMessages,
1111 "anthropic",
1112 "claude-3.5-sonnet",
1113 vec![
1114 ContentBlock::Thinking(ThinkingContent::new("User wants weather.")),
1115 ContentBlock::Text(TextContent::new("Let me check.")),
1116 ContentBlock::ToolCall(ToolCall::new(
1117 "tc_001",
1118 "get_weather",
1119 serde_json::json!({"location": "Paris"}),
1120 )),
1121 ],
1122 ),
1123 tool_result_msg("tc_001", "get_weather", "Rainy, 15°C"),
1124 assistant_msg(
1125 Api::AnthropicMessages,
1126 "anthropic",
1127 "claude-3.5-sonnet",
1128 vec![ContentBlock::Text(TextContent::new(
1129 "It's rainy and 15°C in Paris.",
1130 ))],
1131 ),
1132 ];
1133
1134 let result = anthropic_to_openai(&msgs);
1135 assert_eq!(result.len(), 4, "All 4 messages should be preserved");
1136
1137 match &result[1] {
1139 Message::Assistant(a) => {
1140 let has_tool = a
1141 .content
1142 .iter()
1143 .any(|b| matches!(b, ContentBlock::ToolCall(_)));
1144 assert!(has_tool, "Tool call should be preserved");
1145 let has_thinking = a
1146 .content
1147 .iter()
1148 .any(|b| matches!(b, ContentBlock::Thinking(_)));
1149 assert!(
1150 !has_thinking,
1151 "Thinking should be converted to text for OpenAI"
1152 );
1153 }
1154 _ => panic!("Expected Assistant"),
1155 }
1156
1157 match &result[2] {
1159 Message::ToolResult(t) => {
1160 assert_eq!(t.tool_call_id, "tc_001");
1161 }
1162 _ => panic!("Expected ToolResult"),
1163 }
1164
1165 match &result[3] {
1167 Message::Assistant(a) => {
1168 assert_eq!(a.text_content(), "It's rainy and 15°C in Paris.");
1169 }
1170 _ => panic!("Expected Assistant"),
1171 }
1172 }
1173
1174 #[test]
1177 fn test_images_dropped_with_option() {
1178 let msgs = vec![Message::User(UserMessage::new(vec![
1179 ContentBlock::Text(TextContent::new("Describe this:")),
1180 ContentBlock::Image(ImageContent::new("AAAA", "image/jpeg")),
1181 ]))];
1182
1183 let opts = TransformOptions {
1184 convert_images: false,
1185 ..Default::default()
1186 };
1187 let result =
1188 transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
1189
1190 match &result[0] {
1191 Message::User(u) => match &u.content {
1192 MessageContent::Blocks(blocks) => {
1193 let has_image = blocks.iter().any(|b| matches!(b, ContentBlock::Image(_)));
1194 assert!(!has_image, "Image should be dropped");
1195 assert_eq!(blocks.len(), 1);
1196 }
1197 _ => panic!("Expected blocks"),
1198 },
1199 _ => panic!("Expected User"),
1200 }
1201 }
1202
1203 #[test]
1206 fn test_assistant_metadata_preserved() {
1207 let mut a = AssistantMessage::new(Api::AnthropicMessages, "anthropic", "claude-3.5-sonnet");
1208 a.content = vec![ContentBlock::Text(TextContent::new("Hi"))];
1209 a.usage = Usage {
1210 input: 100,
1211 output: 50,
1212 cache_read: 10,
1213 cache_write: 5,
1214 total_tokens: 165,
1215 cost: Default::default(),
1216 };
1217 a.stop_reason = StopReason::Stop;
1218 a.error_message = None;
1219 a.response_id = Some("msg_abc123".to_string());
1220 let original_ts = a.timestamp;
1221
1222 let msgs = vec![Message::Assistant(a)];
1223 let result = anthropic_to_openai(&msgs);
1224
1225 match &result[0] {
1226 Message::Assistant(a) => {
1227 assert_eq!(a.usage.input, 100);
1228 assert_eq!(a.usage.output, 50);
1229 assert_eq!(a.stop_reason, StopReason::Stop);
1230 assert_eq!(a.response_id, Some("msg_abc123".to_string()));
1231 assert_eq!(a.timestamp, original_ts);
1232 assert_eq!(a.api, Api::OpenAiCompletions);
1233 }
1234 _ => panic!("Expected Assistant"),
1235 }
1236 }
1237
1238 #[test]
1241 fn test_error_tool_result_preserved() {
1242 let err = ToolResultMessage::error("tc_err", "failing_tool", "Something went wrong");
1243 let msgs = vec![Message::ToolResult(err)];
1244
1245 let result = anthropic_to_openai(&msgs);
1246 match &result[0] {
1247 Message::ToolResult(t) => {
1248 assert!(t.is_error);
1249 assert_eq!(t.tool_call_id, "tc_err");
1250 assert_eq!(t.tool_name, "failing_tool");
1251 }
1252 _ => panic!("Expected ToolResult"),
1253 }
1254 }
1255}