1use 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#[derive(Debug, Clone)]
19pub struct TargetModel {
20 pub api: Api,
21 pub provider: Provider,
22 pub model_id: String,
23}
24
25pub 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 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 insert_synthetic_tool_results(transformed)
78}
79
80pub 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 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 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 if is_same_model && inner.thinking_signature.is_some() {
177 return Some(block.clone());
178 }
179
180 if inner.thinking.trim().is_empty() {
182 return None;
183 }
184
185 if is_same_model {
187 return Some(block.clone());
188 }
189
190 Some(Content::text(&inner.thinking))
192 }
193
194 Content::Text { inner } => {
195 if is_same_model {
196 Some(block.clone())
197 } else {
198 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 if !is_same_model {
213 new_call.thought_signature = None;
214 }
215
216 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_orphaned_results(&mut result, &pending_tool_calls, &existing_result_ids);
245 pending_tool_calls.clear();
246 existing_result_ids.clear();
247
248 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 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 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 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 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 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 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 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 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()); }
588 }
589
590 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 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 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 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 assert_eq!(result.len(), 4);
697
698 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 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}