use crate::error::Result;
use crate::types::{
ChatMessage, ChatRole, MediaPart, MediaSource, MultimodalMessage, Provider, ReasoningSegment,
ToolCall, ToolResult, VideoMetadata,
};
use serde_json::{json, Value};
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize)]
pub enum ConversationMessage {
Chat(ChatMessage),
ToolCall(ToolCall),
ToolResult(ToolResult),
Reasoning(ReasoningSegment),
Multimodal(MultimodalMessage),
}
impl From<ChatMessage> for ConversationMessage {
fn from(msg: ChatMessage) -> Self {
ConversationMessage::Chat(msg)
}
}
impl From<ToolCall> for ConversationMessage {
fn from(tool_call: ToolCall) -> Self {
ConversationMessage::ToolCall(tool_call)
}
}
impl From<ToolResult> for ConversationMessage {
fn from(result: ToolResult) -> Self {
ConversationMessage::ToolResult(result)
}
}
impl From<ReasoningSegment> for ConversationMessage {
fn from(segment: ReasoningSegment) -> Self {
ConversationMessage::Reasoning(segment)
}
}
impl From<MultimodalMessage> for ConversationMessage {
fn from(msg: MultimodalMessage) -> Self {
ConversationMessage::Multimodal(msg)
}
}
pub fn media_part_to_google_format(part: &MediaPart, metadata: Option<&VideoMetadata>) -> Value {
match part {
MediaPart::Text { text } => json!({ "text": text }),
MediaPart::Image { source, .. } => media_source_to_google_format(source, None),
MediaPart::Audio { source, .. } => media_source_to_google_format(source, None),
MediaPart::Video {
source,
metadata: video_meta,
} => {
let meta = video_meta.as_ref().or(metadata);
media_source_to_google_format(source, meta)
}
MediaPart::Document { source } => media_source_to_google_format(source, None),
}
}
fn media_source_to_google_format(source: &MediaSource, metadata: Option<&VideoMetadata>) -> Value {
let mut part = match source {
MediaSource::Base64 { data, mime_type } => {
json!({
"inline_data": {
"data": data,
"mime_type": mime_type
}
})
}
MediaSource::Url { url } => {
json!({
"file_data": {
"file_uri": url
}
})
}
MediaSource::FileId { file_id } => {
json!({
"file_data": {
"file_uri": file_id
}
})
}
MediaSource::FileUri { uri, mime_type } => {
let mut fd = json!({
"file_data": {
"file_uri": uri
}
});
if let Some(mt) = mime_type {
fd["file_data"]["mime_type"] = json!(mt);
}
fd
}
};
if let Some(meta) = metadata {
let mut video_metadata = json!({});
if let Some(ref start) = meta.start_offset {
video_metadata["start_offset"] = json!(start);
}
if let Some(ref end) = meta.end_offset {
video_metadata["end_offset"] = json!(end);
}
if let Some(fps) = meta.fps {
video_metadata["fps"] = json!(fps);
}
if video_metadata
.as_object()
.map(|o| !o.is_empty())
.unwrap_or(false)
{
part["video_metadata"] = video_metadata;
}
}
part
}
fn media_part_to_anthropic_format(part: &MediaPart) -> Value {
match part {
MediaPart::Text { text } => json!({
"type": "text",
"text": text
}),
MediaPart::Image { source, .. } => match source {
MediaSource::Base64 { data, mime_type } => json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": data
}
}),
MediaSource::Url { url } => json!({
"type": "image",
"source": {
"type": "url",
"url": url
}
}),
MediaSource::FileId { file_id } => json!({
"type": "image",
"source": {
"type": "file",
"file_id": file_id
}
}),
MediaSource::FileUri { uri, .. } => json!({
"type": "image",
"source": {
"type": "url",
"url": uri
}
}),
},
MediaPart::Document { source } => match source {
MediaSource::Base64 { data, mime_type } => json!({
"type": "document",
"source": {
"type": "base64",
"media_type": mime_type,
"data": data
}
}),
MediaSource::FileId { file_id } => json!({
"type": "document",
"source": {
"type": "file",
"file_id": file_id
}
}),
_ => json!({
"type": "text",
"text": "[Unsupported document source for Anthropic]"
}),
},
MediaPart::Audio { .. } | MediaPart::Video { .. } => json!({
"type": "text",
"text": "[Audio/Video not supported by this provider]"
}),
}
}
fn media_part_to_openai_format(part: &MediaPart) -> Value {
match part {
MediaPart::Text { text } => json!({
"type": "text",
"text": text
}),
MediaPart::Image { source, detail } => {
let mut img = match source {
MediaSource::Base64 { data, mime_type } => json!({
"type": "image_url",
"image_url": {
"url": format!("data:{};base64,{}", mime_type, data)
}
}),
MediaSource::Url { url } => json!({
"type": "image_url",
"image_url": {
"url": url
}
}),
MediaSource::FileId { file_id } => json!({
"type": "image_file",
"image_file": {
"file_id": file_id
}
}),
MediaSource::FileUri { uri, .. } => json!({
"type": "image_url",
"image_url": {
"url": uri
}
}),
};
if let Some(d) = detail {
if let Some(obj) = img.get_mut("image_url") {
obj["detail"] = json!(d);
}
}
img
}
MediaPart::Audio { source, format } => match source {
MediaSource::Base64 { data, .. } => {
let fmt = format.as_deref().unwrap_or("wav");
json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": fmt
}
})
}
_ => json!({
"type": "text",
"text": "[Unsupported audio source for OpenAI - use base64]"
}),
},
MediaPart::Video {
source: MediaSource::Url { url },
..
} => json!({
"type": "video_url",
"video_url": { "url": url }
}),
MediaPart::Video { .. } | MediaPart::Document { .. } => json!({
"type": "text",
"text": "[Video/Document not supported by this provider]"
}),
}
}
fn media_part_to_openai_responses_format(part: &MediaPart) -> Value {
match part {
MediaPart::Text { text } => json!({
"type": "input_text",
"text": text
}),
MediaPart::Image { source, .. } => match source {
MediaSource::Base64 { data, mime_type } => json!({
"type": "input_image",
"image_url": {
"url": format!("data:{};base64,{}", mime_type, data)
}
}),
MediaSource::Url { url } => json!({
"type": "input_image",
"image_url": {
"url": url
}
}),
MediaSource::FileId { file_id } => json!({
"type": "input_image",
"image_file": {
"file_id": file_id
}
}),
MediaSource::FileUri { uri, .. } => json!({
"type": "input_image",
"image_url": {
"url": uri
}
}),
},
MediaPart::Audio { source, format } => match source {
MediaSource::Base64 { data, .. } => {
let fmt = format.as_deref().unwrap_or("wav");
json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": fmt
}
})
}
_ => json!({
"type": "input_text",
"text": "[Unsupported audio source for OpenAI Responses - use base64]"
}),
},
MediaPart::Video { .. } | MediaPart::Document { .. } => json!({
"type": "input_text",
"text": "[Video/Document not supported by this provider]"
}),
}
}
fn multimodal_to_provider_format(msg: &MultimodalMessage, provider: Provider) -> Value {
let role = match msg.role {
ChatRole::System => "system",
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::Tool => "tool",
};
let parts: Vec<Value> = msg
.parts
.iter()
.map(|part| match provider {
Provider::GoogleGenAI => media_part_to_google_format(part, None),
Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
media_part_to_anthropic_format(part)
}
Provider::OpenAI
| Provider::OpenRouter
| Provider::OpenAIResponses
| Provider::OpenRouterResponses => media_part_to_openai_format(part),
})
.collect();
match provider {
Provider::GoogleGenAI => json!({
"role": if role == "assistant" { "model" } else { role },
"parts": parts
}),
_ => json!({
"role": role,
"content": parts
}),
}
}
pub fn convert_messages_to_responses_input(
messages: &[ConversationMessage],
provider: Provider,
) -> Result<(Option<String>, Vec<Value>)> {
let mut instructions: Vec<String> = Vec::new();
let mut input_items: Vec<Value> = Vec::new();
for msg in messages.iter() {
match msg {
ConversationMessage::Chat(chat_msg) => match chat_msg.role {
ChatRole::System => {
instructions.push(chat_msg.content.clone());
}
ChatRole::User => {
input_items.push(json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": chat_msg.content
}]
}));
}
ChatRole::Assistant => {
input_items.push(json!({
"type": "message",
"role": "assistant",
"id": format!("msg_{}", Uuid::new_v4()),
"status": "completed",
"content": [{
"type": "output_text",
"text": chat_msg.content,
"annotations": []
}]
}));
}
ChatRole::Tool => {
input_items.push(json!({
"type": "message",
"role": "tool",
"content": [{
"type": "input_text",
"text": chat_msg.content
}]
}));
}
},
ConversationMessage::ToolCall(tool_call) => {
let args_str = tool_call.raw_arguments.clone().unwrap_or_else(|| {
serde_json::to_string(&tool_call.args).unwrap_or_else(|_| "{}".to_string())
});
input_items.push(json!({
"type": "function_call",
"id": format!("fc_{}", tool_call.tool_use_id),
"call_id": tool_call.tool_use_id,
"name": tool_call.tool_name,
"arguments": args_str
}));
}
ConversationMessage::ToolResult(tool_result) => {
input_items.push(json!({
"type": "function_call_output",
"call_id": tool_result.tool_use_id,
"output": tool_result.content
}));
}
ConversationMessage::Reasoning(reasoning_segment) => {
input_items.push(json!({
"type": "reasoning",
"content": [{
"type": "output_text",
"text": reasoning_segment.content
}]
}));
}
ConversationMessage::Multimodal(multimodal_msg) => {
let role = match multimodal_msg.role {
ChatRole::System => "system",
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::Tool => "tool",
};
let parts: Vec<Value> = multimodal_msg
.parts
.iter()
.map(|part| {
if matches!(
provider,
Provider::OpenAIResponses | Provider::OpenRouterResponses
) {
match part {
MediaPart::Text { text } if role == "assistant" => json!({
"type": "output_text",
"text": text,
"annotations": []
}),
_ => media_part_to_openai_responses_format(part),
}
} else {
media_part_to_openai_format(part)
}
})
.collect();
let mut item = json!({
"type": "message",
"role": role,
"content": parts
});
if role == "assistant" {
item["id"] = json!(format!("msg_{}", Uuid::new_v4()));
item["status"] = json!("completed");
}
input_items.push(item);
}
}
}
let instructions = if instructions.is_empty() {
None
} else {
Some(instructions.join("\n\n"))
};
Ok((instructions, input_items))
}
pub fn convert_messages_to_provider_format(
messages: &[ConversationMessage],
provider: Provider,
) -> Result<Vec<Value>> {
let mut converted_messages: Vec<Value> = Vec::new();
let mut pending_anthropic_blocks: Vec<Value> = Vec::new();
let mut pending_google_parts: Vec<Value> = Vec::new();
let flush_pending = |converted: &mut Vec<Value>,
anthropic_blocks: &mut Vec<Value>,
google_parts: &mut Vec<Value>| {
match provider {
Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
if !anthropic_blocks.is_empty() {
converted.push(json!({
"role": "user",
"content": anthropic_blocks.clone()
}));
anthropic_blocks.clear();
}
}
Provider::GoogleGenAI => {
if !google_parts.is_empty() {
converted.push(json!({
"role": "user",
"content": google_parts.clone()
}));
google_parts.clear();
}
}
_ => {}
}
};
for msg in messages.iter() {
match msg {
ConversationMessage::Chat(chat_msg) => {
if chat_msg.role == ChatRole::User
&& matches!(
provider,
Provider::Anthropic
| Provider::Bedrock
| Provider::GoogleAnthropic
| Provider::GoogleGenAI
)
{
if matches!(
provider,
Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic
) && !pending_anthropic_blocks.is_empty()
{
if !chat_msg.content.is_empty() {
pending_anthropic_blocks.push(json!({
"type": "text",
"text": chat_msg.content
}));
}
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
continue;
} else if provider == Provider::GoogleGenAI && !pending_google_parts.is_empty()
{
if !chat_msg.content.is_empty() {
pending_google_parts.push(json!({
"text": chat_msg.content
}));
}
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
continue;
}
}
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
let role = match chat_msg.role {
ChatRole::System => "system",
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::Tool => "tool",
};
converted_messages.push(json!({
"role": role,
"content": chat_msg.content
}));
}
ConversationMessage::ToolCall(tool_call) => {
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
converted_messages.push(tool_call.to_provider_assistant_message(provider));
}
ConversationMessage::ToolResult(tool_result) => {
match provider {
Provider::OpenAI
| Provider::OpenRouter
| Provider::OpenAIResponses
| Provider::OpenRouterResponses => {
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
converted_messages.push(tool_result.to_provider_format(provider));
}
Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
pending_anthropic_blocks.push(tool_result.to_provider_format(provider));
}
Provider::GoogleGenAI => {
pending_google_parts.push(tool_result.to_provider_format(provider));
}
}
}
ConversationMessage::Reasoning(reasoning_segment) => {
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
converted_messages.push(reasoning_segment.to_provider_format(provider));
}
ConversationMessage::Multimodal(multimodal_msg) => {
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
converted_messages.push(multimodal_to_provider_format(multimodal_msg, provider));
}
}
}
flush_pending(
&mut converted_messages,
&mut pending_anthropic_blocks,
&mut pending_google_parts,
);
Ok(converted_messages)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_openai_no_coalescing() {
let messages = vec![
ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
ConversationMessage::Chat(ChatMessage::user("Thanks!")),
];
let result = convert_messages_to_provider_format(&messages, Provider::OpenAI).unwrap();
assert_eq!(result.len(), 4);
assert_eq!(result[0]["role"], "user");
assert_eq!(result[0]["content"], "Use the weather tool");
assert_eq!(result[1]["role"], "tool");
assert_eq!(result[1]["tool_call_id"], "toolu_1");
assert_eq!(result[2]["role"], "tool");
assert_eq!(result[2]["tool_call_id"], "toolu_2");
assert_eq!(result[3]["role"], "user");
assert_eq!(result[3]["content"], "Thanks!");
}
#[test]
fn test_anthropic_coalesces_tool_results() {
let messages = vec![
ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
];
let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0]["role"], "user");
assert_eq!(result[1]["role"], "user");
let content = result[1]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "toolu_1");
assert_eq!(content[1]["type"], "tool_result");
assert_eq!(content[1]["tool_use_id"], "toolu_2");
}
#[test]
fn test_anthropic_merges_trailing_user_message() {
let messages = vec![
ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
ConversationMessage::Chat(ChatMessage::user("Thanks!")),
];
let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
assert_eq!(result.len(), 2);
let content = result[1]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "Thanks!");
}
#[test]
fn test_google_genai_coalescing() {
let messages = vec![
ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
];
let result = convert_messages_to_provider_format(&messages, Provider::GoogleGenAI).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[1]["role"], "user");
let content = result[1]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert!(content[0].get("functionResponse").is_some());
assert!(content[1].get("functionResponse").is_some());
}
#[test]
fn test_responses_input_conversion() {
let messages = vec![
ConversationMessage::Chat(ChatMessage::system("You are helpful")),
ConversationMessage::Chat(ChatMessage::user("Hello")),
ConversationMessage::ToolResult(ToolResult::success("call_1", "Result")),
];
let (instructions, input) =
convert_messages_to_responses_input(&messages, Provider::OpenAIResponses).unwrap();
assert_eq!(instructions.unwrap(), "You are helpful");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], "user");
assert_eq!(input[0]["content"][0]["type"], "input_text");
assert_eq!(input[1]["type"], "function_call_output");
assert_eq!(input[1]["call_id"], "call_1");
}
#[test]
fn test_google_genai_merges_trailing_user_message() {
let messages = vec![
ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
ConversationMessage::Chat(ChatMessage::user("Thanks!")),
];
let result = convert_messages_to_provider_format(&messages, Provider::GoogleGenAI).unwrap();
assert_eq!(result.len(), 1);
let content = result[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert!(content[0].get("functionResponse").is_some());
assert_eq!(content[1]["text"], "Thanks!");
}
#[test]
fn test_system_message_preserved() {
let messages = vec![
ConversationMessage::Chat(ChatMessage::system("You are helpful")),
ConversationMessage::Chat(ChatMessage::user("Hello")),
];
let result = convert_messages_to_provider_format(&messages, Provider::OpenAI).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0]["role"], "system");
assert_eq!(result[0]["content"], "You are helpful");
}
#[test]
fn test_reasoning_segments_not_coalesced() {
let messages = vec![
ConversationMessage::Chat(ChatMessage::user("Think about this")),
ConversationMessage::Reasoning(ReasoningSegment::with_index("Hmm...", 0)),
ConversationMessage::Chat(ChatMessage::assistant("I understand")),
];
let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[1]["type"], "thinking");
}
}