Skip to main content

oxicode_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 oxicode_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 oxicode_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                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
308/// Convert a single [`IntermediateBlock`] into zero or more [`ContentBlock`]s
309/// appropriate for the target API.
310fn 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                // Anthropic natively supports thinking blocks.
330                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                // All other providers: convert to text wrapped in tags.
340                _ => {
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
381// ---------------------------------------------------------------------------
382// Helpers
383// ---------------------------------------------------------------------------
384
385/// Merge adjacent [`ContentBlock::Text`] blocks into a single block.
386fn 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
429// ---------------------------------------------------------------------------
430// Message normalization (ported from transform-messages.ts)
431// ---------------------------------------------------------------------------
432
433const 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
437/// Replace image content blocks with a text placeholder.
438/// Collapses consecutive placeholders into one.
439fn 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
462/// Downgrade images to text placeholders when the target model does not support vision.
463fn 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
502/// Normalize a tool call ID for cross-provider compatibility.
503///
504/// Delegates to [`crate::utils::normalize_tool_call_id`].
505pub fn normalize_tool_call_id(id: &str) -> String {
506    crate::utils::normalize_tool_call_id(id)
507}
508
509/// Transform messages for a target model, handling:
510/// - Image downgrade for non-vision models
511/// - Thinking block conversion (cross-model → text, same-model → keep)
512/// - Tool call ID normalization
513/// - Tool call thought_signature stripping for cross-model
514/// - Skipping error/aborted assistant messages
515/// - Inserting synthetic tool results for orphaned tool calls
516///
517/// This is the main entry-point matching the behaviour of `transformMessages` in
518/// `transform-messages.ts`.
519pub fn transform_messages_for_model(messages: &[Message], model: &Model) -> Vec<Message> {
520    let image_aware = downgrade_unsupported_images(messages, model);
521
522    // Build a map of original tool call IDs → normalized IDs
523    let mut tool_call_id_map: std::collections::HashMap<String, String> =
524        std::collections::HashMap::new();
525
526    // First pass: transform content blocks
527    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                            // Redacted thinking: only valid for same model
554                            if th.redacted == Some(true) && !is_same_model {
555                                return vec![];
556                            }
557                            // Same model: keep thinking with signatures
558                            if is_same_model && th.thinking_signature.is_some() {
559                                return vec![block.clone()];
560                            }
561                            // Skip empty thinking
562                            if th.thinking.trim().is_empty() {
563                                return vec![];
564                            }
565                            // Same model: keep
566                            if is_same_model {
567                                return vec![block.clone()];
568                            }
569                            // Cross-model: convert to text
570                            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                            // Strip thought_signature for cross-model
579                            if !is_same_model && tc.thought_signature.is_some() {
580                                new_tc.thought_signature = None;
581                            }
582
583                            // Normalize tool call ID for cross-model
584                            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    // Second pass: insert synthetic tool results for orphaned tool calls and
608    // skip error/aborted assistant messages.
609    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 for any pending orphaned tool calls
637                insert_synthetic_results(
638                    &mut pending_tool_calls,
639                    &mut existing_tool_result_ids,
640                    &mut result,
641                );
642
643                // Skip errored/aborted assistant messages
644                if a.stop_reason == StopReason::Error || a.stop_reason == StopReason::Aborted {
645                    continue;
646                }
647
648                // Track tool calls from this assistant message
649                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                // User message interrupts tool flow — insert synthetic results for orphans
666                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    // Handle trailing unresolved tool calls
677    insert_synthetic_results(
678        &mut pending_tool_calls,
679        &mut existing_tool_result_ids,
680        &mut result,
681    );
682
683    result
684}
685
686// ---------------------------------------------------------------------------
687// Convenience directional converters
688// ---------------------------------------------------------------------------
689
690/// Convert Anthropic-format messages to OpenAI format.
691pub 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
700/// Convert OpenAI-format messages to Anthropic format.
701pub 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
710/// Convert Google-format messages to OpenAI format.
711pub 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
720/// Convert Anthropic-format messages to Google format.
721pub 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// ---------------------------------------------------------------------------
731// Tests
732// ---------------------------------------------------------------------------
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::UserMessage;
738
739    /// Helper: create a simple user text message.
740    fn user_msg(text: &str) -> Message {
741        Message::User(UserMessage::new(text))
742    }
743
744    /// Helper: create an assistant message with given content blocks and source API.
745    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    /// Helper: create a tool result message.
752    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 1: Anthropic → OpenAI basic text ----
761
762    #[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        // User message preserved
778        match &result[0] {
779            Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
780            _ => panic!("Expected User message"),
781        }
782
783        // Assistant message converted
784        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 2: Thinking block conversion (Anthropic → OpenAI) ----
794
795    #[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                // Thinking should be wrapped in tags, text preserved
811                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                // No native thinking blocks left
816                assert!(
817                    !a.content
818                        .iter()
819                        .any(|b| matches!(b, ContentBlock::Thinking(_)))
820                );
821            }
822            _ => panic!("Expected Assistant"),
823        }
824    }
825
826    // ---- Test 3: Thinking block strip option ----
827
828    #[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                // Thinking should be completely removed
850                assert_eq!(a.content.len(), 1);
851                assert_eq!(a.text_content(), "Final answer.");
852            }
853            _ => panic!("Expected Assistant"),
854        }
855    }
856
857    // ---- Test 4: Tool call preservation ----
858
859    #[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        // Assistant message should still have the tool call
883        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        // Tool result preserved
895        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 5: Tool calls dropped when convert_tools = false ----
905
906    #[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 6: Image block conversion ----
935
936    #[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 7: OpenAI → Anthropic round-trip preserves text ----
965
966    #[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        // Text content should survive the round trip
982        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 8: Google → OpenAI ----
991
992    #[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 9: Same-API is a no-op clone ----
1017
1018    #[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 10: Thinking preserved when target is Anthropic ----
1035
1036    #[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                // Thinking block should be preserved natively for Anthropic
1053                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 11: Anthropic → Google converts thinking to text ----
1067
1068    #[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                // No native thinking blocks for Google
1085                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                // Text should contain wrapped thinking
1094                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 12: Full conversation with mixed blocks ----
1104
1105    #[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        // First assistant: thinking converted + tool call preserved
1138        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        // Tool result preserved
1158        match &result[2] {
1159            Message::ToolResult(t) => {
1160                assert_eq!(t.tool_call_id, "tc_001");
1161            }
1162            _ => panic!("Expected ToolResult"),
1163        }
1164
1165        // Final assistant: pure text
1166        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 13: Images dropped when convert_images = false ----
1175
1176    #[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 14: Assistant metadata preserved through transform ----
1204
1205    #[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 15: Error tool result preserved ----
1239
1240    #[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}