Skip to main content

alchemy_llm/
transform.rs

1//! Message transformation for cross-provider compatibility.
2//!
3//! Transforms conversation history when switching between models/providers,
4//! handling:
5//! - Thinking block conversion (signature preservation vs text conversion)
6//! - Tool call ID normalization
7//! - Orphaned tool call handling (synthetic error results)
8//! - Error/aborted message filtering
9
10use crate::types::{
11    Api, AssistantMessage, Content, Message, Provider, StopReason, TextContent, ToolCall,
12    ToolCallId, ToolResultContent, ToolResultMessage,
13};
14use std::collections::{HashMap, HashSet};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17/// Information about the target model for transformation.
18#[derive(Debug, Clone)]
19pub struct TargetModel {
20    pub api: Api,
21    pub provider: Provider,
22    pub model_id: String,
23}
24
25/// Transform messages for cross-provider compatibility.
26///
27/// Handles:
28/// - Thinking block conversion (signature preservation vs text conversion)
29/// - Tool call ID normalization
30/// - Orphaned tool call handling (synthetic error results)
31/// - Error/aborted message filtering
32///
33/// # Arguments
34///
35/// * `messages` - The conversation messages to transform
36/// * `target` - The target model information
37/// * `normalize_tool_call_id` - Optional function to normalize tool call IDs
38///
39/// # Example
40///
41/// ```ignore
42/// use alchemy::transform::{transform_messages, TargetModel};
43/// use alchemy::types::{Api, Provider, KnownProvider};
44///
45/// let target = TargetModel {
46///     api: Api::OpenAICompletions,
47///     provider: Provider::Known(KnownProvider::OpenAI),
48///     model_id: "gpt-4o".to_string(),
49/// };
50///
51/// let transformed = transform_messages(&messages, &target, None);
52/// ```
53pub fn transform_messages<F>(
54    messages: &[Message],
55    target: &TargetModel,
56    normalize_tool_call_id: Option<F>,
57) -> Vec<Message>
58where
59    F: Fn(&str, &TargetModel, &AssistantMessage) -> String,
60{
61    let mut tool_call_id_map: HashMap<ToolCallId, ToolCallId> = HashMap::new();
62
63    // First pass: transform messages
64    let transformed: Vec<Message> = messages
65        .iter()
66        .filter_map(|msg| {
67            transform_message(
68                msg,
69                target,
70                normalize_tool_call_id.as_ref(),
71                &mut tool_call_id_map,
72            )
73        })
74        .collect();
75
76    // Second pass: insert synthetic tool results for orphaned calls
77    insert_synthetic_tool_results(transformed)
78}
79
80/// Transform messages without tool call ID normalization.
81///
82/// Convenience wrapper for `transform_messages` when no ID normalization is needed.
83pub fn transform_messages_simple(messages: &[Message], target: &TargetModel) -> Vec<Message> {
84    transform_messages::<fn(&str, &TargetModel, &AssistantMessage) -> String>(
85        messages, target, None,
86    )
87}
88
89fn transform_message<F>(
90    msg: &Message,
91    target: &TargetModel,
92    normalize_fn: Option<&F>,
93    id_map: &mut HashMap<ToolCallId, ToolCallId>,
94) -> Option<Message>
95where
96    F: Fn(&str, &TargetModel, &AssistantMessage) -> String,
97{
98    match msg {
99        Message::User(user) => Some(Message::User(user.clone())),
100
101        Message::ToolResult(result) => {
102            // Apply ID mapping if exists
103            let tool_call_id = id_map
104                .get(&result.tool_call_id)
105                .cloned()
106                .unwrap_or_else(|| result.tool_call_id.clone());
107
108            Some(Message::ToolResult(ToolResultMessage {
109                tool_call_id,
110                tool_name: result.tool_name.clone(),
111                content: result.content.clone(),
112                details: result.details.clone(),
113                is_error: result.is_error,
114                timestamp: result.timestamp,
115            }))
116        }
117
118        Message::Assistant(assistant) => {
119            // Skip errored/aborted messages
120            if matches!(
121                assistant.stop_reason,
122                StopReason::Error | StopReason::Aborted
123            ) {
124                return None;
125            }
126
127            let is_same_model = is_same_model_provider(assistant, target);
128
129            let content = assistant
130                .content
131                .iter()
132                .filter_map(|block| {
133                    transform_content_block(
134                        block,
135                        is_same_model,
136                        target,
137                        assistant,
138                        normalize_fn,
139                        id_map,
140                    )
141                })
142                .collect();
143
144            Some(Message::Assistant(AssistantMessage {
145                content,
146                api: assistant.api,
147                provider: assistant.provider.clone(),
148                model: assistant.model.clone(),
149                usage: assistant.usage.clone(),
150                stop_reason: assistant.stop_reason,
151                error_message: assistant.error_message.clone(),
152                timestamp: assistant.timestamp,
153            }))
154        }
155    }
156}
157
158fn is_same_model_provider(msg: &AssistantMessage, target: &TargetModel) -> bool {
159    msg.provider == target.provider && msg.api == target.api && msg.model == target.model_id
160}
161
162fn transform_content_block<F>(
163    block: &Content,
164    is_same_model: bool,
165    target: &TargetModel,
166    assistant: &AssistantMessage,
167    normalize_fn: Option<&F>,
168    id_map: &mut HashMap<ToolCallId, ToolCallId>,
169) -> Option<Content>
170where
171    F: Fn(&str, &TargetModel, &AssistantMessage) -> String,
172{
173    match block {
174        Content::Thinking { inner } => {
175            // Same model with signature: keep for replay
176            if is_same_model && inner.thinking_signature.is_some() {
177                return Some(block.clone());
178            }
179
180            // Empty thinking: filter out
181            if inner.thinking.trim().is_empty() {
182                return None;
183            }
184
185            // Same model without signature: keep as-is
186            if is_same_model {
187                return Some(block.clone());
188            }
189
190            // Different model: convert to plain text (no <thinking> tags)
191            Some(Content::text(&inner.thinking))
192        }
193
194        Content::Text { inner } => {
195            if is_same_model {
196                Some(block.clone())
197            } else {
198                // Strip signature if present
199                Some(Content::Text {
200                    inner: TextContent {
201                        text: inner.text.clone(),
202                        text_signature: None,
203                    },
204                })
205            }
206        }
207
208        Content::ToolCall { inner } => {
209            let mut new_call = inner.clone();
210
211            // Strip thought signature for different model
212            if !is_same_model {
213                new_call.thought_signature = None;
214            }
215
216            // Normalize ID for different model
217            if !is_same_model {
218                if let Some(normalize) = normalize_fn {
219                    let normalized_id =
220                        ToolCallId::from(normalize(inner.id.as_str(), target, assistant));
221                    if normalized_id != inner.id {
222                        id_map.insert(inner.id.clone(), normalized_id.clone());
223                        new_call.id = normalized_id;
224                    }
225                }
226            }
227
228            Some(Content::ToolCall { inner: new_call })
229        }
230
231        Content::Image { .. } => Some(block.clone()),
232    }
233}
234
235fn insert_synthetic_tool_results(messages: Vec<Message>) -> Vec<Message> {
236    let mut result: Vec<Message> = Vec::new();
237    let mut pending_tool_calls: Vec<ToolCall> = Vec::new();
238    let mut existing_result_ids: HashSet<ToolCallId> = HashSet::new();
239
240    for msg in messages {
241        match &msg {
242            Message::Assistant(assistant) => {
243                // Insert synthetic results for previous orphaned calls
244                insert_orphaned_results(&mut result, &pending_tool_calls, &existing_result_ids);
245                pending_tool_calls.clear();
246                existing_result_ids.clear();
247
248                // Track tool calls from this message
249                for content in &assistant.content {
250                    if let Content::ToolCall { inner } = content {
251                        pending_tool_calls.push(inner.clone());
252                    }
253                }
254
255                result.push(msg);
256            }
257
258            Message::ToolResult(tool_result) => {
259                existing_result_ids.insert(tool_result.tool_call_id.clone());
260                result.push(msg);
261            }
262
263            Message::User(_) => {
264                // User message interrupts tool flow
265                insert_orphaned_results(&mut result, &pending_tool_calls, &existing_result_ids);
266                pending_tool_calls.clear();
267                existing_result_ids.clear();
268
269                result.push(msg);
270            }
271        }
272    }
273
274    // Handle any remaining orphaned calls at the end
275    insert_orphaned_results(&mut result, &pending_tool_calls, &existing_result_ids);
276
277    result
278}
279
280fn insert_orphaned_results(
281    result: &mut Vec<Message>,
282    pending: &[ToolCall],
283    existing: &HashSet<ToolCallId>,
284) {
285    for tc in pending {
286        if !existing.contains(&tc.id) {
287            result.push(Message::ToolResult(ToolResultMessage {
288                tool_call_id: tc.id.clone(),
289                tool_name: tc.name.clone(),
290                content: vec![ToolResultContent::Text(TextContent {
291                    text: "No result provided".to_string(),
292                    text_signature: None,
293                })],
294                details: None,
295                is_error: true,
296                timestamp: current_timestamp(),
297            }));
298        }
299    }
300}
301
302fn current_timestamp() -> i64 {
303    SystemTime::now()
304        .duration_since(UNIX_EPOCH)
305        .map(|d| d.as_millis() as i64)
306        .unwrap_or(0)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::types::{KnownProvider, ThinkingContent, Usage, UserContent, UserMessage};
313
314    fn make_target(api: Api, provider: KnownProvider, model_id: &str) -> TargetModel {
315        TargetModel {
316            api,
317            provider: Provider::Known(provider),
318            model_id: model_id.to_string(),
319        }
320    }
321
322    fn make_assistant(
323        api: Api,
324        provider: KnownProvider,
325        model: &str,
326        content: Vec<Content>,
327    ) -> AssistantMessage {
328        AssistantMessage {
329            content,
330            api,
331            provider: Provider::Known(provider),
332            model: model.to_string(),
333            usage: Usage::default(),
334            stop_reason: StopReason::Stop,
335            error_message: None,
336            timestamp: 0,
337        }
338    }
339
340    fn make_user(text: &str) -> UserMessage {
341        UserMessage {
342            content: UserContent::Text(text.to_string()),
343            timestamp: 0,
344        }
345    }
346
347    fn transform_single_assistant_to_openai(content: Content) -> Vec<Message> {
348        let assistant = make_assistant(
349            Api::AnthropicMessages,
350            KnownProvider::Anthropic,
351            "claude-sonnet-4-20250514",
352            vec![content],
353        );
354        let messages = vec![Message::Assistant(assistant)];
355        let target = make_target(Api::OpenAICompletions, KnownProvider::OpenAI, "gpt-4o");
356
357        transform_messages_simple(&messages, &target)
358    }
359
360    fn assert_single_assistant_message(messages: &[Message]) -> &AssistantMessage {
361        assert_eq!(messages.len(), 1);
362        match &messages[0] {
363            Message::Assistant(assistant) => assistant,
364            _ => panic!("Expected assistant message"),
365        }
366    }
367
368    #[test]
369    fn test_user_message_passthrough() {
370        let messages = vec![Message::User(make_user("Hello"))];
371
372        let target = make_target(Api::OpenAICompletions, KnownProvider::OpenAI, "gpt-4o");
373        let result = transform_messages_simple(&messages, &target);
374
375        assert_eq!(result.len(), 1);
376        assert!(matches!(result[0], Message::User(_)));
377    }
378
379    #[test]
380    fn test_filter_error_messages() {
381        let mut assistant = make_assistant(
382            Api::AnthropicMessages,
383            KnownProvider::Anthropic,
384            "claude-sonnet-4-20250514",
385            vec![Content::text("Some text")],
386        );
387        assistant.stop_reason = StopReason::Error;
388        assistant.error_message = Some("API error".to_string());
389
390        let messages = vec![
391            Message::User(make_user("Hello")),
392            Message::Assistant(assistant),
393        ];
394
395        let target = make_target(
396            Api::AnthropicMessages,
397            KnownProvider::Anthropic,
398            "claude-sonnet-4-20250514",
399        );
400        let result = transform_messages_simple(&messages, &target);
401
402        // Error message should be filtered out
403        assert_eq!(result.len(), 1);
404        assert!(matches!(result[0], Message::User(_)));
405    }
406
407    #[test]
408    fn test_filter_aborted_messages() {
409        let mut assistant = make_assistant(
410            Api::AnthropicMessages,
411            KnownProvider::Anthropic,
412            "claude-sonnet-4-20250514",
413            vec![Content::text("Partial")],
414        );
415        assistant.stop_reason = StopReason::Aborted;
416
417        let messages = vec![Message::Assistant(assistant)];
418
419        let target = make_target(
420            Api::AnthropicMessages,
421            KnownProvider::Anthropic,
422            "claude-sonnet-4-20250514",
423        );
424        let result = transform_messages_simple(&messages, &target);
425
426        assert!(result.is_empty());
427    }
428
429    #[test]
430    fn test_thinking_same_model_with_signature() {
431        let thinking = ThinkingContent {
432            thinking: "Let me think...".to_string(),
433            thinking_signature: Some("sig123".to_string()),
434        };
435        let assistant = make_assistant(
436            Api::AnthropicMessages,
437            KnownProvider::Anthropic,
438            "claude-sonnet-4-20250514",
439            vec![Content::Thinking { inner: thinking }],
440        );
441
442        let messages = vec![Message::Assistant(assistant)];
443
444        let target = make_target(
445            Api::AnthropicMessages,
446            KnownProvider::Anthropic,
447            "claude-sonnet-4-20250514",
448        );
449        let result = transform_messages_simple(&messages, &target);
450
451        // Same model with signature: keep thinking
452        assert_eq!(result.len(), 1);
453        if let Message::Assistant(a) = &result[0] {
454            assert!(matches!(a.content[0], Content::Thinking { .. }));
455        } else {
456            panic!("Expected assistant message");
457        }
458    }
459
460    #[test]
461    fn test_thinking_different_model_to_text() {
462        let thinking = ThinkingContent {
463            thinking: "Let me think about this carefully.".to_string(),
464            thinking_signature: Some("sig123".to_string()),
465        };
466        let result = transform_single_assistant_to_openai(Content::Thinking { inner: thinking });
467
468        // Thinking should be converted to text
469        let assistant = assert_single_assistant_message(&result);
470        assert_eq!(assistant.content.len(), 1);
471        if let Content::Text { inner } = &assistant.content[0] {
472            assert_eq!(inner.text, "Let me think about this carefully.");
473            assert!(inner.text_signature.is_none());
474        } else {
475            panic!("Expected text content");
476        }
477    }
478
479    #[test]
480    fn test_empty_thinking_filtered() {
481        let thinking = ThinkingContent {
482            thinking: "   ".to_string(),
483            thinking_signature: None,
484        };
485        let assistant = make_assistant(
486            Api::AnthropicMessages,
487            KnownProvider::Anthropic,
488            "claude-sonnet-4-20250514",
489            vec![
490                Content::Thinking { inner: thinking },
491                Content::text("Hello!"),
492            ],
493        );
494
495        let messages = vec![Message::Assistant(assistant)];
496
497        let target = make_target(Api::OpenAICompletions, KnownProvider::OpenAI, "gpt-4o");
498        let result = transform_messages_simple(&messages, &target);
499
500        // Empty thinking should be filtered
501        if let Message::Assistant(a) = &result[0] {
502            assert_eq!(a.content.len(), 1);
503            assert!(matches!(a.content[0], Content::Text { .. }));
504        } else {
505            panic!("Expected assistant message");
506        }
507    }
508
509    #[test]
510    fn test_text_signature_stripped_for_different_model() {
511        let text = TextContent {
512            text: "Hello".to_string(),
513            text_signature: Some("sig456".to_string()),
514        };
515        let assistant = make_assistant(
516            Api::AnthropicMessages,
517            KnownProvider::Anthropic,
518            "claude-sonnet-4-20250514",
519            vec![Content::Text { inner: text }],
520        );
521
522        let messages = vec![Message::Assistant(assistant)];
523
524        let target = make_target(Api::OpenAICompletions, KnownProvider::OpenAI, "gpt-4o");
525        let result = transform_messages_simple(&messages, &target);
526
527        if let Message::Assistant(a) = &result[0] {
528            if let Content::Text { inner } = &a.content[0] {
529                assert_eq!(inner.text, "Hello");
530                assert!(inner.text_signature.is_none());
531            } else {
532                panic!("Expected text content");
533            }
534        }
535    }
536
537    #[test]
538    fn test_tool_call_id_normalization() {
539        use serde_json::json;
540
541        let tool_call = ToolCall {
542            id: "original-id-123".into(),
543            name: "search".to_string(),
544            arguments: json!({"query": "test"}),
545            thought_signature: Some("sig".to_string()),
546        };
547        let assistant = make_assistant(
548            Api::AnthropicMessages,
549            KnownProvider::Anthropic,
550            "claude-sonnet-4-20250514",
551            vec![Content::ToolCall { inner: tool_call }],
552        );
553
554        let tool_result = ToolResultMessage {
555            tool_call_id: "original-id-123".into(),
556            tool_name: "search".to_string(),
557            content: vec![ToolResultContent::Text(TextContent {
558                text: "results".to_string(),
559                text_signature: None,
560            })],
561            details: None,
562            is_error: false,
563            timestamp: 0,
564        };
565
566        let messages = vec![
567            Message::Assistant(assistant),
568            Message::ToolResult(tool_result),
569        ];
570
571        let target = make_target(Api::OpenAICompletions, KnownProvider::OpenAI, "gpt-4o");
572
573        // Normalize IDs to OpenAI format
574        let normalize = |id: &str, _target: &TargetModel, _msg: &AssistantMessage| -> String {
575            format!("call_{}", id.replace('-', "_"))
576        };
577
578        let result = transform_messages(&messages, &target, Some(normalize));
579
580        assert_eq!(result.len(), 2);
581
582        // Check tool call ID was normalized
583        if let Message::Assistant(a) = &result[0] {
584            if let Content::ToolCall { inner } = &a.content[0] {
585                assert_eq!(inner.id.as_str(), "call_original_id_123");
586                assert!(inner.thought_signature.is_none()); // Stripped for different model
587            }
588        }
589
590        // Check tool result ID was also normalized
591        if let Message::ToolResult(r) = &result[1] {
592            assert_eq!(r.tool_call_id.as_str(), "call_original_id_123");
593        }
594    }
595
596    #[test]
597    fn test_orphaned_tool_call_synthetic_result() {
598        use serde_json::json;
599
600        let tool_call = ToolCall {
601            id: "call-123".into(),
602            name: "search".to_string(),
603            arguments: json!({"query": "test"}),
604            thought_signature: None,
605        };
606        let assistant = make_assistant(
607            Api::AnthropicMessages,
608            KnownProvider::Anthropic,
609            "claude-sonnet-4-20250514",
610            vec![Content::ToolCall { inner: tool_call }],
611        );
612
613        // User message interrupts without tool result
614        let messages = vec![
615            Message::Assistant(assistant),
616            Message::User(make_user("Never mind")),
617        ];
618
619        let target = make_target(
620            Api::AnthropicMessages,
621            KnownProvider::Anthropic,
622            "claude-sonnet-4-20250514",
623        );
624        let result = transform_messages_simple(&messages, &target);
625
626        // Should have synthetic tool result inserted
627        assert_eq!(result.len(), 3);
628        assert!(matches!(result[0], Message::Assistant(_)));
629
630        if let Message::ToolResult(r) = &result[1] {
631            assert_eq!(r.tool_call_id.as_str(), "call-123");
632            assert_eq!(r.tool_name, "search");
633            assert!(r.is_error);
634        } else {
635            panic!("Expected tool result at index 1");
636        }
637
638        assert!(matches!(result[2], Message::User(_)));
639    }
640
641    #[test]
642    fn test_multiple_tool_calls_partial_results() {
643        use serde_json::json;
644
645        let assistant = make_assistant(
646            Api::AnthropicMessages,
647            KnownProvider::Anthropic,
648            "claude-sonnet-4-20250514",
649            vec![
650                Content::ToolCall {
651                    inner: ToolCall {
652                        id: "call-1".into(),
653                        name: "tool_a".to_string(),
654                        arguments: json!({}),
655                        thought_signature: None,
656                    },
657                },
658                Content::ToolCall {
659                    inner: ToolCall {
660                        id: "call-2".into(),
661                        name: "tool_b".to_string(),
662                        arguments: json!({}),
663                        thought_signature: None,
664                    },
665                },
666            ],
667        );
668
669        // Only one result provided
670        let result1 = ToolResultMessage {
671            tool_call_id: "call-1".into(),
672            tool_name: "tool_a".to_string(),
673            content: vec![ToolResultContent::Text(TextContent {
674                text: "result a".to_string(),
675                text_signature: None,
676            })],
677            details: None,
678            is_error: false,
679            timestamp: 0,
680        };
681
682        let messages = vec![
683            Message::Assistant(assistant),
684            Message::ToolResult(result1),
685            Message::User(make_user("Continue")),
686        ];
687
688        let target = make_target(
689            Api::AnthropicMessages,
690            KnownProvider::Anthropic,
691            "claude-sonnet-4-20250514",
692        );
693        let result = transform_messages_simple(&messages, &target);
694
695        // Should have: assistant, result1, synthetic result for call-2, user
696        assert_eq!(result.len(), 4);
697
698        // Find the synthetic result
699        let synthetic = result.iter().find(|m| {
700            if let Message::ToolResult(r) = m {
701                r.tool_call_id.as_str() == "call-2"
702            } else {
703                false
704            }
705        });
706        assert!(synthetic.is_some());
707
708        if let Some(Message::ToolResult(r)) = synthetic {
709            assert!(r.is_error);
710            assert_eq!(r.tool_name, "tool_b");
711        }
712    }
713
714    #[test]
715    fn test_no_synthetic_when_all_results_present() {
716        use serde_json::json;
717
718        let assistant = make_assistant(
719            Api::AnthropicMessages,
720            KnownProvider::Anthropic,
721            "claude-sonnet-4-20250514",
722            vec![Content::ToolCall {
723                inner: ToolCall {
724                    id: "call-1".into(),
725                    name: "search".to_string(),
726                    arguments: json!({}),
727                    thought_signature: None,
728                },
729            }],
730        );
731
732        let result1 = ToolResultMessage {
733            tool_call_id: "call-1".into(),
734            tool_name: "search".to_string(),
735            content: vec![ToolResultContent::Text(TextContent {
736                text: "found it".to_string(),
737                text_signature: None,
738            })],
739            details: None,
740            is_error: false,
741            timestamp: 0,
742        };
743
744        let messages = vec![Message::Assistant(assistant), Message::ToolResult(result1)];
745
746        let target = make_target(
747            Api::AnthropicMessages,
748            KnownProvider::Anthropic,
749            "claude-sonnet-4-20250514",
750        );
751        let result = transform_messages_simple(&messages, &target);
752
753        // No synthetic results needed
754        assert_eq!(result.len(), 2);
755    }
756
757    #[test]
758    fn test_image_content_passthrough() {
759        use crate::types::ImageContent;
760
761        let image = ImageContent {
762            data: vec![1, 2, 3],
763            mime_type: "image/png".to_string(),
764        };
765        let result = transform_single_assistant_to_openai(Content::Image { inner: image });
766
767        let assistant = assert_single_assistant_message(&result);
768        assert!(matches!(assistant.content[0], Content::Image { .. }));
769    }
770}