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 })
256 }
257
258 IntermediateMessage::Assistant {
259 content,
260 model,
261 provider,
262 usage,
263 stop_reason,
264 error_message,
265 response_id,
266 timestamp,
267 } => {
268 let mut native_blocks: Vec<ContentBlock> = content
269 .iter()
270 .flat_map(|b| intermediate_to_blocks(b, to_api, opts))
271 .collect();
272 if opts.merge_text {
273 native_blocks = merge_adjacent_text_blocks(native_blocks);
274 }
275
276 let mut msg = AssistantMessage::new(*to_api, provider, model);
277 msg.content = native_blocks;
278 msg.usage = usage.clone();
279 msg.stop_reason = *stop_reason;
280 msg.error_message = error_message.clone();
281 msg.response_id = response_id.clone();
282 msg.timestamp = *timestamp;
283 Message::Assistant(msg)
284 }
285
286 IntermediateMessage::ToolResult {
287 tool_call_id,
288 tool_name,
289 content,
290 is_error,
291 } => {
292 let mut native_blocks: Vec<ContentBlock> = content
293 .iter()
294 .flat_map(|b| intermediate_to_blocks(b, to_api, opts))
295 .collect();
296 if opts.merge_text {
297 native_blocks = merge_adjacent_text_blocks(native_blocks);
298 }
299
300 let mut msg = ToolResultMessage::new(tool_call_id, tool_name, native_blocks);
301 msg.is_error = *is_error;
302 Message::ToolResult(msg)
303 }
304 }
305}
306
307fn intermediate_to_blocks(
310 ib: &IntermediateBlock,
311 to_api: &Api,
312 opts: &TransformOptions,
313) -> Vec<ContentBlock> {
314 match ib {
315 IntermediateBlock::Text(text) => {
316 vec![ContentBlock::Text(TextContent {
317 content_type: TextContentType::Text,
318 text: text.clone(),
319 text_signature: None,
320 })]
321 }
322
323 IntermediateBlock::Thinking { text, signature } => {
324 if opts.strip_thinking {
325 return vec![];
326 }
327 match to_api {
328 Api::AnthropicMessages => {
330 let th = ThinkingContent {
331 content_type: crate::ThinkingContentType::Thinking,
332 thinking: text.clone(),
333 thinking_signature: signature.clone(),
334 redacted: None,
335 };
336 vec![ContentBlock::Thinking(th)]
337 }
338 _ => {
340 let wrapped = format!("<thinking>\n{}\n</thinking>", text);
341 vec![ContentBlock::Text(TextContent {
342 content_type: TextContentType::Text,
343 text: wrapped,
344 text_signature: None,
345 })]
346 }
347 }
348 }
349
350 IntermediateBlock::Image { data, mime_type } => {
351 if !opts.convert_images {
352 return vec![];
353 }
354 vec![ContentBlock::Image(ImageContent {
355 content_type: ImageContentType::Image,
356 data: data.clone(),
357 mime_type: mime_type.clone(),
358 })]
359 }
360
361 IntermediateBlock::ToolCall {
362 id,
363 name,
364 arguments,
365 } => {
366 if !opts.convert_tools {
367 return vec![];
368 }
369 vec![ContentBlock::ToolCall(ToolCall {
370 content_type: ToolCallType::ToolCall,
371 id: id.clone(),
372 name: name.clone(),
373 arguments: arguments.clone(),
374 thought_signature: None,
375 })]
376 }
377 }
378}
379
380fn merge_adjacent_text_blocks(blocks: Vec<ContentBlock>) -> Vec<ContentBlock> {
386 let mut result = Vec::with_capacity(blocks.len());
387 let estimated_len = blocks
388 .iter()
389 .map(|b| match b {
390 ContentBlock::Text(t) => t.text.len() + 1,
391 _ => 0,
392 })
393 .sum::<usize>();
394 let mut pending = String::with_capacity(estimated_len.max(256));
395
396 for block in blocks {
397 match block {
398 ContentBlock::Text(t) => {
399 if !pending.is_empty() {
400 pending.push('\n');
401 }
402 pending.push_str(&t.text);
403 }
404 other => {
405 if !pending.is_empty() {
406 result.push(ContentBlock::Text(TextContent {
407 content_type: TextContentType::Text,
408 text: std::mem::take(&mut pending),
409 text_signature: None,
410 }));
411 }
412 result.push(other);
413 }
414 }
415 }
416
417 if !pending.is_empty() {
418 result.push(ContentBlock::Text(TextContent {
419 content_type: TextContentType::Text,
420 text: pending,
421 text_signature: None,
422 }));
423 }
424
425 result
426}
427
428const NON_VISION_USER_IMAGE_PLACEHOLDER: &str = "(image omitted: model does not support images)";
433const NON_VISION_TOOL_IMAGE_PLACEHOLDER: &str =
434 "(tool image omitted: model does not support images)";
435
436fn replace_images_with_placeholder(
439 blocks: &[ContentBlock],
440 placeholder: &str,
441) -> Vec<ContentBlock> {
442 let mut result = Vec::with_capacity(blocks.len());
443 let mut prev_was_placeholder = false;
444
445 for block in blocks {
446 if matches!(block, ContentBlock::Image(_)) {
447 if !prev_was_placeholder {
448 result.push(ContentBlock::Text(TextContent::new(placeholder)));
449 }
450 prev_was_placeholder = true;
451 continue;
452 }
453
454 result.push(block.clone());
455 prev_was_placeholder = matches!(block, ContentBlock::Text(t) if t.text == placeholder);
456 }
457
458 result
459}
460
461fn downgrade_unsupported_images(messages: &[Message], model: &Model) -> Vec<Message> {
463 if model.input.contains(&InputModality::Image) {
464 return messages.to_vec();
465 }
466
467 messages
468 .iter()
469 .map(|msg| match msg {
470 Message::User(u) => match &u.content {
471 MessageContent::Blocks(blocks) => {
472 let replaced =
473 replace_images_with_placeholder(blocks, NON_VISION_USER_IMAGE_PLACEHOLDER);
474 Message::User(crate::UserMessage {
475 role: u.role,
476 content: MessageContent::Blocks(replaced),
477 timestamp: u.timestamp,
478 })
479 }
480 _ => msg.clone(),
481 },
482 Message::ToolResult(t) => {
483 let replaced =
484 replace_images_with_placeholder(&t.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER);
485 Message::ToolResult(ToolResultMessage {
486 role: t.role,
487 tool_call_id: t.tool_call_id.clone(),
488 tool_name: t.tool_name.clone(),
489 content: replaced,
490 details: t.details.clone(),
491 is_error: t.is_error,
492 timestamp: t.timestamp,
493 })
494 }
495 _ => msg.clone(),
496 })
497 .collect()
498}
499
500pub fn normalize_tool_call_id(id: &str) -> String {
504 crate::utils::normalize_tool_call_id(id)
505}
506
507pub fn transform_messages_for_model(messages: &[Message], model: &Model) -> Vec<Message> {
518 let image_aware = downgrade_unsupported_images(messages, model);
519
520 let mut tool_call_id_map: std::collections::HashMap<String, String> =
522 std::collections::HashMap::new();
523
524 let transformed: Vec<Message> = image_aware
526 .iter()
527 .map(|msg| match msg {
528 Message::User(_) => msg.clone(),
529
530 Message::ToolResult(t) => {
531 if let Some(normalized) = tool_call_id_map.get(&t.tool_call_id) {
532 if normalized != &t.tool_call_id {
533 return Message::ToolResult(ToolResultMessage {
534 tool_call_id: normalized.clone(),
535 ..t.clone()
536 });
537 }
538 }
539 msg.clone()
540 }
541
542 Message::Assistant(a) => {
543 let is_same_model =
544 a.provider == model.provider && a.api == model.api && a.model == model.id;
545
546 let new_content: Vec<ContentBlock> = a
547 .content
548 .iter()
549 .flat_map(|block| match block {
550 ContentBlock::Thinking(th) => {
551 if th.redacted == Some(true) && !is_same_model {
553 return vec![];
554 }
555 if is_same_model && th.thinking_signature.is_some() {
557 return vec![block.clone()];
558 }
559 if th.thinking.trim().is_empty() {
561 return vec![];
562 }
563 if is_same_model {
565 return vec![block.clone()];
566 }
567 vec![ContentBlock::Text(TextContent::new(&th.thinking))]
569 }
570
571 ContentBlock::Text(_) => vec![block.clone()],
572
573 ContentBlock::ToolCall(tc) => {
574 let mut new_tc = tc.clone();
575
576 if !is_same_model && tc.thought_signature.is_some() {
578 new_tc.thought_signature = None;
579 }
580
581 if !is_same_model {
583 let normalized = normalize_tool_call_id(&tc.id);
584 if normalized != tc.id {
585 tool_call_id_map.insert(tc.id.clone(), normalized.clone());
586 new_tc.id = normalized;
587 }
588 }
589
590 vec![ContentBlock::ToolCall(new_tc)]
591 }
592
593 _ => vec![block.clone()],
594 })
595 .collect();
596
597 Message::Assistant(AssistantMessage {
598 content: new_content,
599 ..a.clone()
600 })
601 }
602 })
603 .collect();
604
605 let mut result: Vec<Message> = Vec::with_capacity(transformed.len());
608 let mut pending_tool_calls: Vec<ToolCall> = Vec::new();
609 let mut existing_tool_result_ids: std::collections::HashSet<String> =
610 std::collections::HashSet::new();
611
612 let insert_synthetic_results = |pending: &mut Vec<ToolCall>,
613 existing: &mut std::collections::HashSet<String>,
614 out: &mut Vec<Message>| {
615 for tc in pending.drain(..) {
616 if !existing.contains(&tc.id) {
617 out.push(Message::ToolResult(ToolResultMessage {
618 role: crate::ToolResultRole::ToolResult,
619 tool_call_id: tc.id.clone(),
620 tool_name: tc.name.clone(),
621 content: vec![ContentBlock::Text(TextContent::new("No result provided"))],
622 details: None,
623 is_error: true,
624 timestamp: chrono::Utc::now().timestamp_millis(),
625 }));
626 }
627 }
628 existing.clear();
629 };
630
631 for msg in &transformed {
632 match msg {
633 Message::Assistant(a) => {
634 insert_synthetic_results(
636 &mut pending_tool_calls,
637 &mut existing_tool_result_ids,
638 &mut result,
639 );
640
641 if a.stop_reason == StopReason::Error || a.stop_reason == StopReason::Aborted {
643 continue;
644 }
645
646 let tool_calls: Vec<&ToolCall> =
648 a.content.iter().filter_map(|b| b.as_tool_call()).collect();
649 if !tool_calls.is_empty() {
650 pending_tool_calls = tool_calls.into_iter().cloned().collect();
651 existing_tool_result_ids.clear();
652 }
653
654 result.push(msg.clone());
655 }
656
657 Message::ToolResult(t) => {
658 existing_tool_result_ids.insert(t.tool_call_id.clone());
659 result.push(msg.clone());
660 }
661
662 Message::User(_) => {
663 insert_synthetic_results(
665 &mut pending_tool_calls,
666 &mut existing_tool_result_ids,
667 &mut result,
668 );
669 result.push(msg.clone());
670 }
671 }
672 }
673
674 insert_synthetic_results(
676 &mut pending_tool_calls,
677 &mut existing_tool_result_ids,
678 &mut result,
679 );
680
681 result
682}
683
684pub fn anthropic_to_openai(messages: &[Message]) -> Vec<Message> {
690 transform_messages(
691 messages,
692 Api::AnthropicMessages,
693 Api::OpenAiCompletions,
694 TransformOptions::default(),
695 )
696}
697
698pub fn openai_to_anthropic(messages: &[Message]) -> Vec<Message> {
700 transform_messages(
701 messages,
702 Api::OpenAiCompletions,
703 Api::AnthropicMessages,
704 TransformOptions::default(),
705 )
706}
707
708pub fn google_to_openai(messages: &[Message]) -> Vec<Message> {
710 transform_messages(
711 messages,
712 Api::GoogleGenerativeAi,
713 Api::OpenAiCompletions,
714 TransformOptions::default(),
715 )
716}
717
718pub fn anthropic_to_google(messages: &[Message]) -> Vec<Message> {
720 transform_messages(
721 messages,
722 Api::AnthropicMessages,
723 Api::GoogleGenerativeAi,
724 TransformOptions::default(),
725 )
726}
727
728#[cfg(test)]
733mod tests {
734 use super::*;
735 use crate::UserMessage;
736
737 fn user_msg(text: &str) -> Message {
739 Message::User(UserMessage::new(text))
740 }
741
742 fn assistant_msg(api: Api, provider: &str, model: &str, blocks: Vec<ContentBlock>) -> Message {
744 let mut msg = AssistantMessage::new(api, provider, model);
745 msg.content = blocks;
746 Message::Assistant(msg)
747 }
748
749 fn tool_result_msg(tool_call_id: &str, tool_name: &str, text: &str) -> Message {
751 Message::ToolResult(ToolResultMessage::new(
752 tool_call_id,
753 tool_name,
754 vec![ContentBlock::Text(TextContent::new(text))],
755 ))
756 }
757
758 #[test]
761 fn test_anthropic_to_openai_text() {
762 let msgs = vec![
763 user_msg("Hello"),
764 assistant_msg(
765 Api::AnthropicMessages,
766 "anthropic",
767 "claude-3.5-sonnet",
768 vec![ContentBlock::Text(TextContent::new("Hi there!"))],
769 ),
770 ];
771
772 let result = anthropic_to_openai(&msgs);
773 assert_eq!(result.len(), 2);
774
775 match &result[0] {
777 Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
778 _ => panic!("Expected User message"),
779 }
780
781 match &result[1] {
783 Message::Assistant(a) => {
784 assert_eq!(a.api, Api::OpenAiCompletions);
785 assert_eq!(a.text_content(), "Hi there!");
786 }
787 _ => panic!("Expected Assistant message"),
788 }
789 }
790
791 #[test]
794 fn test_thinking_block_anthropic_to_openai() {
795 let msgs = vec![assistant_msg(
796 Api::AnthropicMessages,
797 "anthropic",
798 "claude-3.5-sonnet",
799 vec![
800 ContentBlock::Thinking(ThinkingContent::new("Let me think...")),
801 ContentBlock::Text(TextContent::new("Here's the answer.")),
802 ],
803 )];
804
805 let result = anthropic_to_openai(&msgs);
806 match &result[0] {
807 Message::Assistant(a) => {
808 let text = a.text_content();
810 assert!(text.contains("<thinking>"));
811 assert!(text.contains("Let me think..."));
812 assert!(text.contains("Here's the answer."));
813 assert!(!a
815 .content
816 .iter()
817 .any(|b| matches!(b, ContentBlock::Thinking(_))));
818 }
819 _ => panic!("Expected Assistant"),
820 }
821 }
822
823 #[test]
826 fn test_thinking_block_stripped() {
827 let msgs = vec![assistant_msg(
828 Api::AnthropicMessages,
829 "anthropic",
830 "claude-3.5-sonnet",
831 vec![
832 ContentBlock::Thinking(ThinkingContent::new("Internal thought")),
833 ContentBlock::Text(TextContent::new("Final answer.")),
834 ],
835 )];
836
837 let opts = TransformOptions {
838 strip_thinking: true,
839 ..Default::default()
840 };
841 let result =
842 transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
843
844 match &result[0] {
845 Message::Assistant(a) => {
846 assert_eq!(a.content.len(), 1);
848 assert_eq!(a.text_content(), "Final answer.");
849 }
850 _ => panic!("Expected Assistant"),
851 }
852 }
853
854 #[test]
857 fn test_tool_calls_preserved() {
858 let tool_call = ContentBlock::ToolCall(ToolCall::new(
859 "call_123",
860 "get_weather",
861 serde_json::json!({"city": "Tokyo"}),
862 ));
863
864 let msgs = vec![
865 assistant_msg(
866 Api::AnthropicMessages,
867 "anthropic",
868 "claude-3.5-sonnet",
869 vec![
870 ContentBlock::Text(TextContent::new("Let me check.")),
871 tool_call,
872 ],
873 ),
874 tool_result_msg("call_123", "get_weather", "Sunny, 22°C"),
875 ];
876
877 let result = anthropic_to_openai(&msgs);
878
879 match &result[0] {
881 Message::Assistant(a) => {
882 let tc = a.content.iter().find_map(|b| b.as_tool_call());
883 assert!(tc.is_some(), "Tool call should be preserved");
884 let tc = tc.unwrap();
885 assert_eq!(tc.id, "call_123");
886 assert_eq!(tc.name, "get_weather");
887 }
888 _ => panic!("Expected Assistant"),
889 }
890
891 match &result[1] {
893 Message::ToolResult(t) => {
894 assert_eq!(t.tool_call_id, "call_123");
895 assert_eq!(t.tool_name, "get_weather");
896 }
897 _ => panic!("Expected ToolResult"),
898 }
899 }
900
901 #[test]
904 fn test_tool_calls_dropped_with_option() {
905 let msgs = vec![assistant_msg(
906 Api::AnthropicMessages,
907 "anthropic",
908 "claude-3.5-sonnet",
909 vec![
910 ContentBlock::Text(TextContent::new("I will call a tool.")),
911 ContentBlock::ToolCall(ToolCall::new("tc_1", "search", serde_json::json!({}))),
912 ],
913 )];
914
915 let opts = TransformOptions {
916 convert_tools: false,
917 ..Default::default()
918 };
919 let result =
920 transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
921
922 match &result[0] {
923 Message::Assistant(a) => {
924 assert_eq!(a.content.len(), 1);
925 assert_eq!(a.text_content(), "I will call a tool.");
926 }
927 _ => panic!("Expected Assistant"),
928 }
929 }
930
931 #[test]
934 fn test_image_block_conversion() {
935 let msgs = vec![assistant_msg(
936 Api::AnthropicMessages,
937 "anthropic",
938 "claude-3.5-sonnet",
939 vec![
940 ContentBlock::Text(TextContent::new("Here's the image:")),
941 ContentBlock::Image(ImageContent::new("iVBORw0KGgo=", "image/png")),
942 ],
943 )];
944
945 let result = anthropic_to_openai(&msgs);
946
947 match &result[0] {
948 Message::Assistant(a) => {
949 let has_text = a.content.iter().any(|b| matches!(b, ContentBlock::Text(_)));
950 let has_image = a
951 .content
952 .iter()
953 .any(|b| matches!(b, ContentBlock::Image(_)));
954 assert!(has_text, "Text block should be preserved");
955 assert!(has_image, "Image block should be preserved");
956 }
957 _ => panic!("Expected Assistant"),
958 }
959 }
960
961 #[test]
964 fn test_openai_to_anthropic_roundtrip() {
965 let original = vec![
966 user_msg("What is 2+2?"),
967 assistant_msg(
968 Api::OpenAiCompletions,
969 "openai",
970 "gpt-4o",
971 vec![ContentBlock::Text(TextContent::new("The answer is 4."))],
972 ),
973 ];
974
975 let to_anthropic = openai_to_anthropic(&original);
976 let back_to_openai = anthropic_to_openai(&to_anthropic);
977
978 match (&original[1], &back_to_openai[1]) {
980 (Message::Assistant(orig), Message::Assistant(rt)) => {
981 assert_eq!(orig.text_content(), rt.text_content());
982 }
983 _ => panic!("Expected Assistant messages"),
984 }
985 }
986
987 #[test]
990 fn test_google_to_openai() {
991 let msgs = vec![
992 user_msg("Summarize this"),
993 assistant_msg(
994 Api::GoogleGenerativeAi,
995 "google",
996 "gemini-2.0-flash",
997 vec![ContentBlock::Text(TextContent::new("Here's a summary."))],
998 ),
999 ];
1000
1001 let result = google_to_openai(&msgs);
1002 assert_eq!(result.len(), 2);
1003
1004 match &result[1] {
1005 Message::Assistant(a) => {
1006 assert_eq!(a.api, Api::OpenAiCompletions);
1007 assert_eq!(a.text_content(), "Here's a summary.");
1008 }
1009 _ => panic!("Expected Assistant"),
1010 }
1011 }
1012
1013 #[test]
1016 fn test_same_api_noop() {
1017 let msgs = vec![user_msg("Hello")];
1018 let result = transform_messages(
1019 &msgs,
1020 Api::AnthropicMessages,
1021 Api::AnthropicMessages,
1022 TransformOptions::default(),
1023 );
1024 assert_eq!(result.len(), 1);
1025 match &result[0] {
1026 Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
1027 _ => panic!("Expected User"),
1028 }
1029 }
1030
1031 #[test]
1034 fn test_thinking_preserved_for_anthropic_target() {
1035 let msgs = vec![assistant_msg(
1036 Api::OpenAiCompletions,
1037 "openai",
1038 "gpt-4o",
1039 vec![
1040 ContentBlock::Thinking(ThinkingContent::new("Reasoning...")),
1041 ContentBlock::Text(TextContent::new("Answer.")),
1042 ],
1043 )];
1044
1045 let result = openai_to_anthropic(&msgs);
1046
1047 match &result[0] {
1048 Message::Assistant(a) => {
1049 let has_thinking = a
1051 .content
1052 .iter()
1053 .any(|b| matches!(b, ContentBlock::Thinking(_)));
1054 assert!(
1055 has_thinking,
1056 "Thinking block should be preserved for Anthropic"
1057 );
1058 }
1059 _ => panic!("Expected Assistant"),
1060 }
1061 }
1062
1063 #[test]
1066 fn test_anthropic_to_google_thinking() {
1067 let msgs = vec![assistant_msg(
1068 Api::AnthropicMessages,
1069 "anthropic",
1070 "claude-3.5-sonnet",
1071 vec![
1072 ContentBlock::Thinking(ThinkingContent::new("Deep thought")),
1073 ContentBlock::Text(TextContent::new("Result.")),
1074 ],
1075 )];
1076
1077 let result = anthropic_to_google(&msgs);
1078
1079 match &result[0] {
1080 Message::Assistant(a) => {
1081 let has_thinking = a
1083 .content
1084 .iter()
1085 .any(|b| matches!(b, ContentBlock::Thinking(_)));
1086 assert!(
1087 !has_thinking,
1088 "Google target should not have thinking blocks"
1089 );
1090 let text = a.text_content();
1092 assert!(text.contains("<thinking>"));
1093 assert!(text.contains("Deep thought"));
1094 assert!(text.contains("Result."));
1095 }
1096 _ => panic!("Expected Assistant"),
1097 }
1098 }
1099
1100 #[test]
1103 fn test_full_conversation_mixed_blocks() {
1104 let msgs = vec![
1105 user_msg("What's the weather in Paris?"),
1106 assistant_msg(
1107 Api::AnthropicMessages,
1108 "anthropic",
1109 "claude-3.5-sonnet",
1110 vec![
1111 ContentBlock::Thinking(ThinkingContent::new("User wants weather.")),
1112 ContentBlock::Text(TextContent::new("Let me check.")),
1113 ContentBlock::ToolCall(ToolCall::new(
1114 "tc_001",
1115 "get_weather",
1116 serde_json::json!({"location": "Paris"}),
1117 )),
1118 ],
1119 ),
1120 tool_result_msg("tc_001", "get_weather", "Rainy, 15°C"),
1121 assistant_msg(
1122 Api::AnthropicMessages,
1123 "anthropic",
1124 "claude-3.5-sonnet",
1125 vec![ContentBlock::Text(TextContent::new(
1126 "It's rainy and 15°C in Paris.",
1127 ))],
1128 ),
1129 ];
1130
1131 let result = anthropic_to_openai(&msgs);
1132 assert_eq!(result.len(), 4, "All 4 messages should be preserved");
1133
1134 match &result[1] {
1136 Message::Assistant(a) => {
1137 let has_tool = a
1138 .content
1139 .iter()
1140 .any(|b| matches!(b, ContentBlock::ToolCall(_)));
1141 assert!(has_tool, "Tool call should be preserved");
1142 let has_thinking = a
1143 .content
1144 .iter()
1145 .any(|b| matches!(b, ContentBlock::Thinking(_)));
1146 assert!(
1147 !has_thinking,
1148 "Thinking should be converted to text for OpenAI"
1149 );
1150 }
1151 _ => panic!("Expected Assistant"),
1152 }
1153
1154 match &result[2] {
1156 Message::ToolResult(t) => {
1157 assert_eq!(t.tool_call_id, "tc_001");
1158 }
1159 _ => panic!("Expected ToolResult"),
1160 }
1161
1162 match &result[3] {
1164 Message::Assistant(a) => {
1165 assert_eq!(a.text_content(), "It's rainy and 15°C in Paris.");
1166 }
1167 _ => panic!("Expected Assistant"),
1168 }
1169 }
1170
1171 #[test]
1174 fn test_images_dropped_with_option() {
1175 let msgs = vec![Message::User(UserMessage::new(vec![
1176 ContentBlock::Text(TextContent::new("Describe this:")),
1177 ContentBlock::Image(ImageContent::new("AAAA", "image/jpeg")),
1178 ]))];
1179
1180 let opts = TransformOptions {
1181 convert_images: false,
1182 ..Default::default()
1183 };
1184 let result =
1185 transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
1186
1187 match &result[0] {
1188 Message::User(u) => match &u.content {
1189 MessageContent::Blocks(blocks) => {
1190 let has_image = blocks.iter().any(|b| matches!(b, ContentBlock::Image(_)));
1191 assert!(!has_image, "Image should be dropped");
1192 assert_eq!(blocks.len(), 1);
1193 }
1194 _ => panic!("Expected blocks"),
1195 },
1196 _ => panic!("Expected User"),
1197 }
1198 }
1199
1200 #[test]
1203 fn test_assistant_metadata_preserved() {
1204 let mut a = AssistantMessage::new(Api::AnthropicMessages, "anthropic", "claude-3.5-sonnet");
1205 a.content = vec![ContentBlock::Text(TextContent::new("Hi"))];
1206 a.usage = Usage {
1207 input: 100,
1208 output: 50,
1209 cache_read: 10,
1210 cache_write: 5,
1211 total_tokens: 165,
1212 cost: Default::default(),
1213 };
1214 a.stop_reason = StopReason::Stop;
1215 a.error_message = None;
1216 a.response_id = Some("msg_abc123".to_string());
1217 let original_ts = a.timestamp;
1218
1219 let msgs = vec![Message::Assistant(a)];
1220 let result = anthropic_to_openai(&msgs);
1221
1222 match &result[0] {
1223 Message::Assistant(a) => {
1224 assert_eq!(a.usage.input, 100);
1225 assert_eq!(a.usage.output, 50);
1226 assert_eq!(a.stop_reason, StopReason::Stop);
1227 assert_eq!(a.response_id, Some("msg_abc123".to_string()));
1228 assert_eq!(a.timestamp, original_ts);
1229 assert_eq!(a.api, Api::OpenAiCompletions);
1230 }
1231 _ => panic!("Expected Assistant"),
1232 }
1233 }
1234
1235 #[test]
1238 fn test_error_tool_result_preserved() {
1239 let err = ToolResultMessage::error("tc_err", "failing_tool", "Something went wrong");
1240 let msgs = vec![Message::ToolResult(err)];
1241
1242 let result = anthropic_to_openai(&msgs);
1243 match &result[0] {
1244 Message::ToolResult(t) => {
1245 assert!(t.is_error);
1246 assert_eq!(t.tool_call_id, "tc_err");
1247 assert_eq!(t.tool_name, "failing_tool");
1248 }
1249 _ => panic!("Expected ToolResult"),
1250 }
1251 }
1252}