1use crate::error::Result;
7use crate::types::{
8 ChatMessage, ChatRole, MediaPart, MediaSource, MultimodalMessage, Provider, ReasoningSegment,
9 ToolCall, ToolResult, VideoMetadata,
10};
11use serde_json::{json, Value};
12use uuid::Uuid;
13
14#[derive(Debug, Clone, serde::Serialize)]
16pub enum ConversationMessage {
17 Chat(ChatMessage),
19 ToolCall(ToolCall),
21 ToolResult(ToolResult),
23 Reasoning(ReasoningSegment),
25 Multimodal(MultimodalMessage),
27}
28
29impl From<ChatMessage> for ConversationMessage {
30 fn from(msg: ChatMessage) -> Self {
31 ConversationMessage::Chat(msg)
32 }
33}
34
35impl From<ToolCall> for ConversationMessage {
36 fn from(tool_call: ToolCall) -> Self {
37 ConversationMessage::ToolCall(tool_call)
38 }
39}
40
41impl From<ToolResult> for ConversationMessage {
42 fn from(result: ToolResult) -> Self {
43 ConversationMessage::ToolResult(result)
44 }
45}
46
47impl From<ReasoningSegment> for ConversationMessage {
48 fn from(segment: ReasoningSegment) -> Self {
49 ConversationMessage::Reasoning(segment)
50 }
51}
52
53impl From<MultimodalMessage> for ConversationMessage {
54 fn from(msg: MultimodalMessage) -> Self {
55 ConversationMessage::Multimodal(msg)
56 }
57}
58
59pub fn media_part_to_google_format(part: &MediaPart, metadata: Option<&VideoMetadata>) -> Value {
61 match part {
62 MediaPart::Text { text } => json!({ "text": text }),
63
64 MediaPart::Image { source, .. } => media_source_to_google_format(source, None),
65
66 MediaPart::Audio { source, .. } => media_source_to_google_format(source, None),
67
68 MediaPart::Video {
69 source,
70 metadata: video_meta,
71 } => {
72 let meta = video_meta.as_ref().or(metadata);
73 media_source_to_google_format(source, meta)
74 }
75
76 MediaPart::Document { source } => media_source_to_google_format(source, None),
77 }
78}
79
80fn media_source_to_google_format(source: &MediaSource, metadata: Option<&VideoMetadata>) -> Value {
82 let mut part = match source {
83 MediaSource::Base64 { data, mime_type } => {
84 json!({
85 "inline_data": {
86 "data": data,
87 "mime_type": mime_type
88 }
89 })
90 }
91 MediaSource::Url { url } => {
92 json!({
95 "file_data": {
96 "file_uri": url
97 }
98 })
99 }
100 MediaSource::FileId { file_id } => {
101 json!({
102 "file_data": {
103 "file_uri": file_id
104 }
105 })
106 }
107 MediaSource::FileUri { uri, mime_type } => {
108 let mut fd = json!({
109 "file_data": {
110 "file_uri": uri
111 }
112 });
113 if let Some(mt) = mime_type {
114 fd["file_data"]["mime_type"] = json!(mt);
115 }
116 fd
117 }
118 };
119
120 if let Some(meta) = metadata {
122 let mut video_metadata = json!({});
123 if let Some(ref start) = meta.start_offset {
124 video_metadata["start_offset"] = json!(start);
125 }
126 if let Some(ref end) = meta.end_offset {
127 video_metadata["end_offset"] = json!(end);
128 }
129 if let Some(fps) = meta.fps {
130 video_metadata["fps"] = json!(fps);
131 }
132 if video_metadata
133 .as_object()
134 .map(|o| !o.is_empty())
135 .unwrap_or(false)
136 {
137 part["video_metadata"] = video_metadata;
138 }
139 }
140
141 part
142}
143
144fn media_part_to_anthropic_format(part: &MediaPart) -> Value {
146 match part {
147 MediaPart::Text { text } => json!({
148 "type": "text",
149 "text": text
150 }),
151
152 MediaPart::Image { source, .. } => match source {
153 MediaSource::Base64 { data, mime_type } => json!({
154 "type": "image",
155 "source": {
156 "type": "base64",
157 "media_type": mime_type,
158 "data": data
159 }
160 }),
161 MediaSource::Url { url } => json!({
162 "type": "image",
163 "source": {
164 "type": "url",
165 "url": url
166 }
167 }),
168 MediaSource::FileId { file_id } => json!({
169 "type": "image",
170 "source": {
171 "type": "file",
172 "file_id": file_id
173 }
174 }),
175 MediaSource::FileUri { uri, .. } => json!({
176 "type": "image",
177 "source": {
178 "type": "url",
179 "url": uri
180 }
181 }),
182 },
183
184 MediaPart::Document { source } => match source {
185 MediaSource::Base64 { data, mime_type } => json!({
186 "type": "document",
187 "source": {
188 "type": "base64",
189 "media_type": mime_type,
190 "data": data
191 }
192 }),
193 MediaSource::FileId { file_id } => json!({
194 "type": "document",
195 "source": {
196 "type": "file",
197 "file_id": file_id
198 }
199 }),
200 _ => json!({
201 "type": "text",
202 "text": "[Unsupported document source for Anthropic]"
203 }),
204 },
205
206 MediaPart::Audio { .. } | MediaPart::Video { .. } => json!({
208 "type": "text",
209 "text": "[Audio/Video not supported by this provider]"
210 }),
211 }
212}
213
214fn media_part_to_openai_format(part: &MediaPart) -> Value {
216 match part {
217 MediaPart::Text { text } => json!({
218 "type": "text",
219 "text": text
220 }),
221
222 MediaPart::Image { source, detail } => {
223 let mut img = match source {
224 MediaSource::Base64 { data, mime_type } => json!({
225 "type": "image_url",
226 "image_url": {
227 "url": format!("data:{};base64,{}", mime_type, data)
228 }
229 }),
230 MediaSource::Url { url } => json!({
231 "type": "image_url",
232 "image_url": {
233 "url": url
234 }
235 }),
236 MediaSource::FileId { file_id } => json!({
237 "type": "image_file",
238 "image_file": {
239 "file_id": file_id
240 }
241 }),
242 MediaSource::FileUri { uri, .. } => json!({
243 "type": "image_url",
244 "image_url": {
245 "url": uri
246 }
247 }),
248 };
249 if let Some(d) = detail {
250 if let Some(obj) = img.get_mut("image_url") {
251 obj["detail"] = json!(d);
252 }
253 }
254 img
255 }
256
257 MediaPart::Audio { source, format } => match source {
258 MediaSource::Base64 { data, .. } => {
259 let fmt = format.as_deref().unwrap_or("wav");
260 json!({
261 "type": "input_audio",
262 "input_audio": {
263 "data": data,
264 "format": fmt
265 }
266 })
267 }
268 _ => json!({
269 "type": "text",
270 "text": "[Unsupported audio source for OpenAI - use base64]"
271 }),
272 },
273
274 MediaPart::Video {
276 source: MediaSource::Url { url },
277 ..
278 } => json!({
279 "type": "video_url",
280 "video_url": { "url": url }
281 }),
282 MediaPart::Video { .. } | MediaPart::Document { .. } => json!({
283 "type": "text",
284 "text": "[Video/Document not supported by this provider]"
285 }),
286 }
287}
288
289fn media_part_to_openai_responses_format(part: &MediaPart) -> Value {
291 match part {
292 MediaPart::Text { text } => json!({
293 "type": "input_text",
294 "text": text
295 }),
296
297 MediaPart::Image { source, .. } => match source {
298 MediaSource::Base64 { data, mime_type } => json!({
299 "type": "input_image",
300 "image_url": {
301 "url": format!("data:{};base64,{}", mime_type, data)
302 }
303 }),
304 MediaSource::Url { url } => json!({
305 "type": "input_image",
306 "image_url": {
307 "url": url
308 }
309 }),
310 MediaSource::FileId { file_id } => json!({
311 "type": "input_image",
312 "image_file": {
313 "file_id": file_id
314 }
315 }),
316 MediaSource::FileUri { uri, .. } => json!({
317 "type": "input_image",
318 "image_url": {
319 "url": uri
320 }
321 }),
322 },
323
324 MediaPart::Audio { source, format } => match source {
325 MediaSource::Base64 { data, .. } => {
326 let fmt = format.as_deref().unwrap_or("wav");
327 json!({
328 "type": "input_audio",
329 "input_audio": {
330 "data": data,
331 "format": fmt
332 }
333 })
334 }
335 _ => json!({
336 "type": "input_text",
337 "text": "[Unsupported audio source for OpenAI Responses - use base64]"
338 }),
339 },
340
341 MediaPart::Video { .. } | MediaPart::Document { .. } => json!({
343 "type": "input_text",
344 "text": "[Video/Document not supported by this provider]"
345 }),
346 }
347}
348
349fn multimodal_to_provider_format(msg: &MultimodalMessage, provider: Provider) -> Value {
351 let role = match msg.role {
352 ChatRole::System => "system",
353 ChatRole::User => "user",
354 ChatRole::Assistant => "assistant",
355 ChatRole::Tool => "tool",
356 };
357
358 let parts: Vec<Value> = msg
359 .parts
360 .iter()
361 .map(|part| match provider {
362 Provider::GoogleGenAI => media_part_to_google_format(part, None),
363 Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
364 media_part_to_anthropic_format(part)
365 }
366 Provider::OpenAI
367 | Provider::OpenRouter
368 | Provider::OpenAIResponses
369 | Provider::OpenRouterResponses => media_part_to_openai_format(part),
370 })
371 .collect();
372
373 match provider {
375 Provider::GoogleGenAI => json!({
376 "role": if role == "assistant" { "model" } else { role },
377 "parts": parts
378 }),
379 _ => json!({
380 "role": role,
381 "content": parts
382 }),
383 }
384}
385
386pub fn convert_messages_to_responses_input(
390 messages: &[ConversationMessage],
391 provider: Provider,
392) -> Result<(Option<String>, Vec<Value>)> {
393 let mut instructions: Vec<String> = Vec::new();
394 let mut input_items: Vec<Value> = Vec::new();
395
396 for msg in messages.iter() {
397 match msg {
398 ConversationMessage::Chat(chat_msg) => match chat_msg.role {
399 ChatRole::System => {
400 instructions.push(chat_msg.content.clone());
401 }
402 ChatRole::User => {
403 input_items.push(json!({
404 "type": "message",
405 "role": "user",
406 "content": [{
407 "type": "input_text",
408 "text": chat_msg.content
409 }]
410 }));
411 }
412 ChatRole::Assistant => {
413 input_items.push(json!({
414 "type": "message",
415 "role": "assistant",
416 "id": format!("msg_{}", Uuid::new_v4()),
417 "status": "completed",
418 "content": [{
419 "type": "output_text",
420 "text": chat_msg.content,
421 "annotations": []
422 }]
423 }));
424 }
425 ChatRole::Tool => {
426 input_items.push(json!({
427 "type": "message",
428 "role": "tool",
429 "content": [{
430 "type": "input_text",
431 "text": chat_msg.content
432 }]
433 }));
434 }
435 },
436 ConversationMessage::ToolCall(tool_call) => {
437 let args_str = tool_call.raw_arguments.clone().unwrap_or_else(|| {
438 serde_json::to_string(&tool_call.args).unwrap_or_else(|_| "{}".to_string())
439 });
440 input_items.push(json!({
441 "type": "function_call",
442 "id": format!("fc_{}", tool_call.tool_use_id),
443 "call_id": tool_call.tool_use_id,
444 "name": tool_call.tool_name,
445 "arguments": args_str
446 }));
447 }
448 ConversationMessage::ToolResult(tool_result) => {
449 input_items.push(json!({
450 "type": "function_call_output",
451 "call_id": tool_result.tool_use_id,
452 "output": tool_result.content
453 }));
454 }
455 ConversationMessage::Reasoning(reasoning_segment) => {
456 input_items.push(json!({
457 "type": "reasoning",
458 "content": [{
459 "type": "output_text",
460 "text": reasoning_segment.content
461 }]
462 }));
463 }
464 ConversationMessage::Multimodal(multimodal_msg) => {
465 let role = match multimodal_msg.role {
466 ChatRole::System => "system",
467 ChatRole::User => "user",
468 ChatRole::Assistant => "assistant",
469 ChatRole::Tool => "tool",
470 };
471
472 let parts: Vec<Value> = multimodal_msg
473 .parts
474 .iter()
475 .map(|part| {
476 if matches!(
477 provider,
478 Provider::OpenAIResponses | Provider::OpenRouterResponses
479 ) {
480 match part {
481 MediaPart::Text { text } if role == "assistant" => json!({
482 "type": "output_text",
483 "text": text,
484 "annotations": []
485 }),
486 _ => media_part_to_openai_responses_format(part),
487 }
488 } else {
489 media_part_to_openai_format(part)
490 }
491 })
492 .collect();
493
494 let mut item = json!({
495 "type": "message",
496 "role": role,
497 "content": parts
498 });
499
500 if role == "assistant" {
501 item["id"] = json!(format!("msg_{}", Uuid::new_v4()));
502 item["status"] = json!("completed");
503 }
504
505 input_items.push(item);
506 }
507 }
508 }
509
510 let instructions = if instructions.is_empty() {
511 None
512 } else {
513 Some(instructions.join("\n\n"))
514 };
515
516 Ok((instructions, input_items))
517}
518
519pub fn convert_messages_to_provider_format(
534 messages: &[ConversationMessage],
535 provider: Provider,
536) -> Result<Vec<Value>> {
537 let mut converted_messages: Vec<Value> = Vec::new();
538 let mut pending_anthropic_blocks: Vec<Value> = Vec::new();
539 let mut pending_google_parts: Vec<Value> = Vec::new();
540
541 let flush_pending = |converted: &mut Vec<Value>,
542 anthropic_blocks: &mut Vec<Value>,
543 google_parts: &mut Vec<Value>| {
544 match provider {
545 Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
546 if !anthropic_blocks.is_empty() {
547 converted.push(json!({
548 "role": "user",
549 "content": anthropic_blocks.clone()
550 }));
551 anthropic_blocks.clear();
552 }
553 }
554 Provider::GoogleGenAI => {
555 if !google_parts.is_empty() {
556 converted.push(json!({
557 "role": "user",
558 "content": google_parts.clone()
559 }));
560 google_parts.clear();
561 }
562 }
563 _ => {}
564 }
565 };
566
567 for msg in messages.iter() {
568 match msg {
569 ConversationMessage::Chat(chat_msg) => {
570 if chat_msg.role == ChatRole::User
572 && matches!(
573 provider,
574 Provider::Anthropic
575 | Provider::Bedrock
576 | Provider::GoogleAnthropic
577 | Provider::GoogleGenAI
578 )
579 {
580 if matches!(
582 provider,
583 Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic
584 ) && !pending_anthropic_blocks.is_empty()
585 {
586 if !chat_msg.content.is_empty() {
589 pending_anthropic_blocks.push(json!({
590 "type": "text",
591 "text": chat_msg.content
592 }));
593 }
594 flush_pending(
595 &mut converted_messages,
596 &mut pending_anthropic_blocks,
597 &mut pending_google_parts,
598 );
599 continue;
600 } else if provider == Provider::GoogleGenAI && !pending_google_parts.is_empty()
601 {
602 if !chat_msg.content.is_empty() {
604 pending_google_parts.push(json!({
605 "text": chat_msg.content
606 }));
607 }
608 flush_pending(
609 &mut converted_messages,
610 &mut pending_anthropic_blocks,
611 &mut pending_google_parts,
612 );
613 continue;
614 }
615 }
616
617 flush_pending(
619 &mut converted_messages,
620 &mut pending_anthropic_blocks,
621 &mut pending_google_parts,
622 );
623
624 let role = match chat_msg.role {
626 ChatRole::System => "system",
627 ChatRole::User => "user",
628 ChatRole::Assistant => "assistant",
629 ChatRole::Tool => "tool",
630 };
631
632 converted_messages.push(json!({
633 "role": role,
634 "content": chat_msg.content
635 }));
636 }
637
638 ConversationMessage::ToolCall(tool_call) => {
639 flush_pending(
641 &mut converted_messages,
642 &mut pending_anthropic_blocks,
643 &mut pending_google_parts,
644 );
645
646 converted_messages.push(tool_call.to_provider_assistant_message(provider));
648 }
649
650 ConversationMessage::ToolResult(tool_result) => {
651 match provider {
652 Provider::OpenAI
653 | Provider::OpenRouter
654 | Provider::OpenAIResponses
655 | Provider::OpenRouterResponses => {
656 flush_pending(
658 &mut converted_messages,
659 &mut pending_anthropic_blocks,
660 &mut pending_google_parts,
661 );
662 converted_messages.push(tool_result.to_provider_format(provider));
663 }
664 Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
665 pending_anthropic_blocks.push(tool_result.to_provider_format(provider));
667 }
668 Provider::GoogleGenAI => {
669 pending_google_parts.push(tool_result.to_provider_format(provider));
671 }
672 }
673 }
674
675 ConversationMessage::Reasoning(reasoning_segment) => {
676 flush_pending(
678 &mut converted_messages,
679 &mut pending_anthropic_blocks,
680 &mut pending_google_parts,
681 );
682
683 converted_messages.push(reasoning_segment.to_provider_format(provider));
685 }
686
687 ConversationMessage::Multimodal(multimodal_msg) => {
688 flush_pending(
690 &mut converted_messages,
691 &mut pending_anthropic_blocks,
692 &mut pending_google_parts,
693 );
694
695 converted_messages.push(multimodal_to_provider_format(multimodal_msg, provider));
697 }
698 }
699 }
700
701 flush_pending(
703 &mut converted_messages,
704 &mut pending_anthropic_blocks,
705 &mut pending_google_parts,
706 );
707
708 Ok(converted_messages)
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 #[test]
716 fn test_openai_no_coalescing() {
717 let messages = vec![
719 ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
720 ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
721 ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
722 ConversationMessage::Chat(ChatMessage::user("Thanks!")),
723 ];
724
725 let result = convert_messages_to_provider_format(&messages, Provider::OpenAI).unwrap();
726
727 assert_eq!(result.len(), 4);
729
730 assert_eq!(result[0]["role"], "user");
732 assert_eq!(result[0]["content"], "Use the weather tool");
733
734 assert_eq!(result[1]["role"], "tool");
736 assert_eq!(result[1]["tool_call_id"], "toolu_1");
737
738 assert_eq!(result[2]["role"], "tool");
740 assert_eq!(result[2]["tool_call_id"], "toolu_2");
741
742 assert_eq!(result[3]["role"], "user");
744 assert_eq!(result[3]["content"], "Thanks!");
745 }
746
747 #[test]
748 fn test_anthropic_coalesces_tool_results() {
749 let messages = vec![
751 ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
752 ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
753 ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
754 ];
755
756 let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
757
758 assert_eq!(result.len(), 2);
760
761 assert_eq!(result[0]["role"], "user");
763
764 assert_eq!(result[1]["role"], "user");
766 let content = result[1]["content"].as_array().unwrap();
767 assert_eq!(content.len(), 2);
768 assert_eq!(content[0]["type"], "tool_result");
769 assert_eq!(content[0]["tool_use_id"], "toolu_1");
770 assert_eq!(content[1]["type"], "tool_result");
771 assert_eq!(content[1]["tool_use_id"], "toolu_2");
772 }
773
774 #[test]
775 fn test_anthropic_merges_trailing_user_message() {
776 let messages = vec![
778 ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
779 ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
780 ConversationMessage::Chat(ChatMessage::user("Thanks!")),
781 ];
782
783 let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
784
785 assert_eq!(result.len(), 2);
787
788 let content = result[1]["content"].as_array().unwrap();
790 assert_eq!(content.len(), 2);
791 assert_eq!(content[0]["type"], "tool_result");
792 assert_eq!(content[1]["type"], "text");
793 assert_eq!(content[1]["text"], "Thanks!");
794 }
795
796 #[test]
797 fn test_google_genai_coalescing() {
798 let messages = vec![
800 ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
801 ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
802 ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
803 ];
804
805 let result = convert_messages_to_provider_format(&messages, Provider::GoogleGenAI).unwrap();
806
807 assert_eq!(result.len(), 2);
809
810 assert_eq!(result[1]["role"], "user");
812 let content = result[1]["content"].as_array().unwrap();
813 assert_eq!(content.len(), 2);
814 assert!(content[0].get("functionResponse").is_some());
815 assert!(content[1].get("functionResponse").is_some());
816 }
817
818 #[test]
819 fn test_responses_input_conversion() {
820 let messages = vec![
821 ConversationMessage::Chat(ChatMessage::system("You are helpful")),
822 ConversationMessage::Chat(ChatMessage::user("Hello")),
823 ConversationMessage::ToolResult(ToolResult::success("call_1", "Result")),
824 ];
825
826 let (instructions, input) =
827 convert_messages_to_responses_input(&messages, Provider::OpenAIResponses).unwrap();
828
829 assert_eq!(instructions.unwrap(), "You are helpful");
830 assert_eq!(input.len(), 2);
831 assert_eq!(input[0]["role"], "user");
832 assert_eq!(input[0]["content"][0]["type"], "input_text");
833 assert_eq!(input[1]["type"], "function_call_output");
834 assert_eq!(input[1]["call_id"], "call_1");
835 }
836
837 #[test]
838 fn test_google_genai_merges_trailing_user_message() {
839 let messages = vec![
841 ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
842 ConversationMessage::Chat(ChatMessage::user("Thanks!")),
843 ];
844
845 let result = convert_messages_to_provider_format(&messages, Provider::GoogleGenAI).unwrap();
846
847 assert_eq!(result.len(), 1);
849
850 let content = result[0]["content"].as_array().unwrap();
851 assert_eq!(content.len(), 2);
852 assert!(content[0].get("functionResponse").is_some());
853 assert_eq!(content[1]["text"], "Thanks!");
854 }
855
856 #[test]
857 fn test_system_message_preserved() {
858 let messages = vec![
859 ConversationMessage::Chat(ChatMessage::system("You are helpful")),
860 ConversationMessage::Chat(ChatMessage::user("Hello")),
861 ];
862
863 let result = convert_messages_to_provider_format(&messages, Provider::OpenAI).unwrap();
864
865 assert_eq!(result.len(), 2);
866 assert_eq!(result[0]["role"], "system");
867 assert_eq!(result[0]["content"], "You are helpful");
868 }
869
870 #[test]
871 fn test_reasoning_segments_not_coalesced() {
872 let messages = vec![
873 ConversationMessage::Chat(ChatMessage::user("Think about this")),
874 ConversationMessage::Reasoning(ReasoningSegment::with_index("Hmm...", 0)),
875 ConversationMessage::Chat(ChatMessage::assistant("I understand")),
876 ];
877
878 let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
879
880 assert_eq!(result.len(), 3);
882 assert_eq!(result[1]["type"], "thinking");
883 }
884}