Skip to main content

oxi_ai/
transform.rs

1//! Cross-provider message transformation.
2//!
3//! When switching models mid-conversation (e.g. Claude → GPT), message formats
4//! need to be converted so the target provider can understand the history.
5//!
6//! # Supported conversions
7//!
8//! - **Anthropic ↔ OpenAI**: Tool calls (`tool_use` ↔ `function`), thinking blocks,
9//!   image data URIs.
10//! - **Google → OpenAI**: Function calls, inline images.
11//! - **Any → Any**: Falls back through OpenAI as a universal intermediate.
12//!
13//! # Usage
14//!
15//! ```ignore
16//! use oxi_ai::transform::{transform_messages, TransformOptions};
17//!
18//! let converted = transform_messages(
19//!     &messages,
20//!     Api::AnthropicMessages,
21//!     Api::OpenAiCompletions,
22//!     TransformOptions::default(),
23//! );
24//! ```
25
26use 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// ---------------------------------------------------------------------------
35// Options
36// ---------------------------------------------------------------------------
37
38/// Options that control how messages are transformed between providers.
39#[derive(Debug, Clone)]
40pub struct TransformOptions {
41    /// Strip thinking blocks entirely instead of converting them to text.
42    pub strip_thinking: bool,
43    /// Convert tool calls / tool results (when `false`, tool calls are dropped).
44    pub convert_tools: bool,
45    /// Convert image blocks (when `false`, images are dropped).
46    pub convert_images: bool,
47    /// Merge adjacent text blocks produced by the transformation.
48    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
62// ---------------------------------------------------------------------------
63// Top-level API
64// ---------------------------------------------------------------------------
65
66/// Transform a slice of [`Message`]s from one provider API to another.
67///
68/// This is the primary entry-point.  It dispatches to the appropriate
69/// directional converter and then applies the requested post-processing
70/// options.
71///
72/// # Cross-provider transformation flow
73///
74/// ```ignore
75/// use oxi_ai::{transform_messages, Api};
76///
77/// // Convert messages from Anthropic format to OpenAI format
78/// let anthropic_msgs = vec![/* ... */];
79/// let openai_msgs = transform_messages(
80///     &anthropic_msgs,
81///     Api::AnthropicMessages,
82///     Api::OpenAiCompletions,
83///     Default::default(),
84/// );
85/// ```
86pub 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    // Convert every message through an internal JSON-based intermediate
97    // representation, then back into native `Message`s for the target API.
98    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// ---------------------------------------------------------------------------
110// Intermediate (provider-neutral) representation
111// ---------------------------------------------------------------------------
112
113/// A provider-neutral representation of a single message, used as the
114/// intermediate format during cross-provider conversion.
115#[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/// Provider-neutral content for a user message.
139#[derive(Debug, Clone)]
140enum IntermediateContent {
141    Text(String),
142    Blocks(Vec<IntermediateBlock>),
143}
144
145/// Provider-neutral content block.
146#[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
164// ---------------------------------------------------------------------------
165// Source → Intermediate
166// ---------------------------------------------------------------------------
167
168/// Convert a native [`Message`] into the intermediate representation.
169fn 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            // Best-effort: try to extract text.
219            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
228// ---------------------------------------------------------------------------
229// Intermediate → Target
230// ---------------------------------------------------------------------------
231
232/// Convert an [`IntermediateMessage`] into a native [`Message`] for the target API.
233fn 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
307/// Convert a single [`IntermediateBlock`] into zero or more [`ContentBlock`]s
308/// appropriate for the target API.
309fn 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                // Anthropic natively supports thinking blocks.
329                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                // All other providers: convert to text wrapped in tags.
339                _ => {
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
380// ---------------------------------------------------------------------------
381// Helpers
382// ---------------------------------------------------------------------------
383
384/// Merge adjacent [`ContentBlock::Text`] blocks into a single block.
385fn 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
428// ---------------------------------------------------------------------------
429// Message normalization (ported from transform-messages.ts)
430// ---------------------------------------------------------------------------
431
432const 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
436/// Replace image content blocks with a text placeholder.
437/// Collapses consecutive placeholders into one.
438fn 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
461/// Downgrade images to text placeholders when the target model does not support vision.
462fn 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
500/// Normalize a tool call ID for cross-provider compatibility.
501///
502/// Delegates to [`crate::utils::normalize_tool_call_id`].
503pub fn normalize_tool_call_id(id: &str) -> String {
504    crate::utils::normalize_tool_call_id(id)
505}
506
507/// Transform messages for a target model, handling:
508/// - Image downgrade for non-vision models
509/// - Thinking block conversion (cross-model → text, same-model → keep)
510/// - Tool call ID normalization
511/// - Tool call thought_signature stripping for cross-model
512/// - Skipping error/aborted assistant messages
513/// - Inserting synthetic tool results for orphaned tool calls
514///
515/// This is the main entry-point matching the behaviour of `transformMessages` in
516/// `transform-messages.ts`.
517pub fn transform_messages_for_model(messages: &[Message], model: &Model) -> Vec<Message> {
518    let image_aware = downgrade_unsupported_images(messages, model);
519
520    // Build a map of original tool call IDs → normalized IDs
521    let mut tool_call_id_map: std::collections::HashMap<String, String> =
522        std::collections::HashMap::new();
523
524    // First pass: transform content blocks
525    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                            // Redacted thinking: only valid for same model
552                            if th.redacted == Some(true) && !is_same_model {
553                                return vec![];
554                            }
555                            // Same model: keep thinking with signatures
556                            if is_same_model && th.thinking_signature.is_some() {
557                                return vec![block.clone()];
558                            }
559                            // Skip empty thinking
560                            if th.thinking.trim().is_empty() {
561                                return vec![];
562                            }
563                            // Same model: keep
564                            if is_same_model {
565                                return vec![block.clone()];
566                            }
567                            // Cross-model: convert to text
568                            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                            // Strip thought_signature for cross-model
577                            if !is_same_model && tc.thought_signature.is_some() {
578                                new_tc.thought_signature = None;
579                            }
580
581                            // Normalize tool call ID for cross-model
582                            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    // Second pass: insert synthetic tool results for orphaned tool calls and
606    // skip error/aborted assistant messages.
607    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 for any pending orphaned tool calls
635                insert_synthetic_results(
636                    &mut pending_tool_calls,
637                    &mut existing_tool_result_ids,
638                    &mut result,
639                );
640
641                // Skip errored/aborted assistant messages
642                if a.stop_reason == StopReason::Error || a.stop_reason == StopReason::Aborted {
643                    continue;
644                }
645
646                // Track tool calls from this assistant message
647                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                // User message interrupts tool flow — insert synthetic results for orphans
664                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    // Handle trailing unresolved tool calls
675    insert_synthetic_results(
676        &mut pending_tool_calls,
677        &mut existing_tool_result_ids,
678        &mut result,
679    );
680
681    result
682}
683
684// ---------------------------------------------------------------------------
685// Convenience directional converters
686// ---------------------------------------------------------------------------
687
688/// Convert Anthropic-format messages to OpenAI format.
689pub 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
698/// Convert OpenAI-format messages to Anthropic format.
699pub 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
708/// Convert Google-format messages to OpenAI format.
709pub 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
718/// Convert Anthropic-format messages to Google format.
719pub 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// ---------------------------------------------------------------------------
729// Tests
730// ---------------------------------------------------------------------------
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::UserMessage;
736
737    /// Helper: create a simple user text message.
738    fn user_msg(text: &str) -> Message {
739        Message::User(UserMessage::new(text))
740    }
741
742    /// Helper: create an assistant message with given content blocks and source API.
743    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    /// Helper: create a tool result message.
750    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 1: Anthropic → OpenAI basic text ----
759
760    #[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        // User message preserved
776        match &result[0] {
777            Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
778            _ => panic!("Expected User message"),
779        }
780
781        // Assistant message converted
782        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 2: Thinking block conversion (Anthropic → OpenAI) ----
792
793    #[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                // Thinking should be wrapped in tags, text preserved
809                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                // No native thinking blocks left
814                assert!(!a
815                    .content
816                    .iter()
817                    .any(|b| matches!(b, ContentBlock::Thinking(_))));
818            }
819            _ => panic!("Expected Assistant"),
820        }
821    }
822
823    // ---- Test 3: Thinking block strip option ----
824
825    #[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                // Thinking should be completely removed
847                assert_eq!(a.content.len(), 1);
848                assert_eq!(a.text_content(), "Final answer.");
849            }
850            _ => panic!("Expected Assistant"),
851        }
852    }
853
854    // ---- Test 4: Tool call preservation ----
855
856    #[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        // Assistant message should still have the tool call
880        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        // Tool result preserved
892        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 5: Tool calls dropped when convert_tools = false ----
902
903    #[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 6: Image block conversion ----
932
933    #[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 7: OpenAI → Anthropic round-trip preserves text ----
962
963    #[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        // Text content should survive the round trip
979        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 8: Google → OpenAI ----
988
989    #[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 9: Same-API is a no-op clone ----
1014
1015    #[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 10: Thinking preserved when target is Anthropic ----
1032
1033    #[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                // Thinking block should be preserved natively for Anthropic
1050                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 11: Anthropic → Google converts thinking to text ----
1064
1065    #[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                // No native thinking blocks for Google
1082                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                // Text should contain wrapped thinking
1091                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 12: Full conversation with mixed blocks ----
1101
1102    #[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        // First assistant: thinking converted + tool call preserved
1135        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        // Tool result preserved
1155        match &result[2] {
1156            Message::ToolResult(t) => {
1157                assert_eq!(t.tool_call_id, "tc_001");
1158            }
1159            _ => panic!("Expected ToolResult"),
1160        }
1161
1162        // Final assistant: pure text
1163        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 13: Images dropped when convert_images = false ----
1172
1173    #[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 14: Assistant metadata preserved through transform ----
1201
1202    #[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 15: Error tool result preserved ----
1236
1237    #[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}