use serde_json::Value as JsonValue;
use crate::{
Api, AssistantMessage, ContentBlock, ImageContent, ImageContentType, InputModality, Message,
MessageContent, Model, StopReason, TextContent, TextContentType, ThinkingContent, ToolCall,
ToolCallType, ToolResultMessage, Usage,
};
#[derive(Debug, Clone)]
pub struct TransformOptions {
pub strip_thinking: bool,
pub convert_tools: bool,
pub convert_images: bool,
pub merge_text: bool,
}
impl Default for TransformOptions {
fn default() -> Self {
Self {
strip_thinking: false,
convert_tools: true,
convert_images: true,
merge_text: true,
}
}
}
pub fn transform_messages(
messages: &[Message],
from_api: Api,
to_api: Api,
opts: TransformOptions,
) -> Vec<Message> {
if from_api == to_api {
return messages.to_vec();
}
let intermediate: Vec<IntermediateMessage> = messages
.iter()
.map(|m| to_intermediate(m, &from_api))
.collect();
intermediate
.into_iter()
.map(|im| from_intermediate(&im, &to_api, &opts))
.collect()
}
#[derive(Debug, Clone)]
enum IntermediateMessage {
User {
content: IntermediateContent,
},
Assistant {
content: Vec<IntermediateBlock>,
model: String,
provider: String,
usage: Usage,
stop_reason: StopReason,
error_message: Option<String>,
response_id: Option<String>,
timestamp: i64,
},
ToolResult {
tool_call_id: String,
tool_name: String,
content: Vec<IntermediateBlock>,
is_error: bool,
},
}
#[derive(Debug, Clone)]
enum IntermediateContent {
Text(String),
Blocks(Vec<IntermediateBlock>),
}
#[derive(Debug, Clone)]
enum IntermediateBlock {
Text(String),
Thinking {
text: String,
signature: Option<String>,
},
Image {
data: String,
mime_type: String,
},
ToolCall {
id: String,
name: String,
arguments: JsonValue,
},
}
fn to_intermediate(msg: &Message, _from_api: &Api) -> IntermediateMessage {
match msg {
Message::User(u) => {
let content = match &u.content {
MessageContent::Text(s) => IntermediateContent::Text(s.clone()),
MessageContent::Blocks(blocks) => {
IntermediateContent::Blocks(blocks.iter().map(block_to_intermediate).collect())
}
};
IntermediateMessage::User { content }
}
Message::Assistant(a) => IntermediateMessage::Assistant {
content: a.content.iter().map(block_to_intermediate).collect(),
model: a.model.clone(),
provider: a.provider.clone(),
usage: a.usage.clone(),
stop_reason: a.stop_reason,
error_message: a.error_message.clone(),
response_id: a.response_id.clone(),
timestamp: a.timestamp,
},
Message::ToolResult(t) => IntermediateMessage::ToolResult {
tool_call_id: t.tool_call_id.clone(),
tool_name: t.tool_name.clone(),
content: t.content.iter().map(block_to_intermediate).collect(),
is_error: t.is_error,
},
}
}
fn block_to_intermediate(block: &ContentBlock) -> IntermediateBlock {
match block {
ContentBlock::Text(t) => IntermediateBlock::Text(t.text.clone()),
ContentBlock::Thinking(th) => IntermediateBlock::Thinking {
text: th.thinking.clone(),
signature: th.thinking_signature.clone(),
},
ContentBlock::Image(img) => IntermediateBlock::Image {
data: img.data.clone(),
mime_type: img.mime_type.clone(),
},
ContentBlock::ToolCall(tc) => IntermediateBlock::ToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
},
ContentBlock::Unknown(val) => {
if let Some(text) = val.get("text").and_then(|v| v.as_str()) {
IntermediateBlock::Text(text.to_string())
} else {
IntermediateBlock::Text(format!("[unknown block: {}]", val))
}
}
}
}
fn from_intermediate(im: &IntermediateMessage, to_api: &Api, opts: &TransformOptions) -> Message {
match im {
IntermediateMessage::User { content } => {
let native_content = match content {
IntermediateContent::Text(s) => MessageContent::Text(s.clone()),
IntermediateContent::Blocks(blocks) => {
let native_blocks: Vec<ContentBlock> = blocks
.iter()
.flat_map(|b| intermediate_to_blocks(b, to_api, opts))
.collect();
let merged = if opts.merge_text {
merge_adjacent_text_blocks(native_blocks)
} else {
native_blocks
};
MessageContent::Blocks(merged)
}
};
Message::User(crate::UserMessage {
role: crate::UserRole::User,
content: native_content,
timestamp: chrono::Utc::now().timestamp_millis(),
})
}
IntermediateMessage::Assistant {
content,
model,
provider,
usage,
stop_reason,
error_message,
response_id,
timestamp,
} => {
let mut native_blocks: Vec<ContentBlock> = content
.iter()
.flat_map(|b| intermediate_to_blocks(b, to_api, opts))
.collect();
if opts.merge_text {
native_blocks = merge_adjacent_text_blocks(native_blocks);
}
let mut msg = AssistantMessage::new(*to_api, provider, model);
msg.content = native_blocks;
msg.usage = usage.clone();
msg.stop_reason = *stop_reason;
msg.error_message = error_message.clone();
msg.response_id = response_id.clone();
msg.timestamp = *timestamp;
Message::Assistant(msg)
}
IntermediateMessage::ToolResult {
tool_call_id,
tool_name,
content,
is_error,
} => {
let mut native_blocks: Vec<ContentBlock> = content
.iter()
.flat_map(|b| intermediate_to_blocks(b, to_api, opts))
.collect();
if opts.merge_text {
native_blocks = merge_adjacent_text_blocks(native_blocks);
}
let mut msg = ToolResultMessage::new(tool_call_id, tool_name, native_blocks);
msg.is_error = *is_error;
Message::ToolResult(msg)
}
}
}
fn intermediate_to_blocks(
ib: &IntermediateBlock,
to_api: &Api,
opts: &TransformOptions,
) -> Vec<ContentBlock> {
match ib {
IntermediateBlock::Text(text) => {
vec![ContentBlock::Text(TextContent {
content_type: TextContentType::Text,
text: text.clone(),
text_signature: None,
})]
}
IntermediateBlock::Thinking { text, signature } => {
if opts.strip_thinking {
return vec![];
}
match to_api {
Api::AnthropicMessages => {
let th = ThinkingContent {
content_type: crate::ThinkingContentType::Thinking,
thinking: text.clone(),
thinking_signature: signature.clone(),
redacted: None,
};
vec![ContentBlock::Thinking(th)]
}
_ => {
let wrapped = format!("<thinking>\n{}\n</thinking>", text);
vec![ContentBlock::Text(TextContent {
content_type: TextContentType::Text,
text: wrapped,
text_signature: None,
})]
}
}
}
IntermediateBlock::Image { data, mime_type } => {
if !opts.convert_images {
return vec![];
}
vec![ContentBlock::Image(ImageContent {
content_type: ImageContentType::Image,
data: data.clone(),
mime_type: mime_type.clone(),
})]
}
IntermediateBlock::ToolCall {
id,
name,
arguments,
} => {
if !opts.convert_tools {
return vec![];
}
vec![ContentBlock::ToolCall(ToolCall {
content_type: ToolCallType::ToolCall,
id: id.clone(),
name: name.clone(),
arguments: arguments.clone(),
thought_signature: None,
})]
}
}
}
fn merge_adjacent_text_blocks(blocks: Vec<ContentBlock>) -> Vec<ContentBlock> {
let mut result = Vec::with_capacity(blocks.len());
let estimated_len = blocks
.iter()
.map(|b| match b {
ContentBlock::Text(t) => t.text.len() + 1,
_ => 0,
})
.sum::<usize>();
let mut pending = String::with_capacity(estimated_len.max(256));
for block in blocks {
match block {
ContentBlock::Text(t) => {
if !pending.is_empty() {
pending.push('\n');
}
pending.push_str(&t.text);
}
other => {
if !pending.is_empty() {
result.push(ContentBlock::Text(TextContent {
content_type: TextContentType::Text,
text: std::mem::take(&mut pending),
text_signature: None,
}));
}
result.push(other);
}
}
}
if !pending.is_empty() {
result.push(ContentBlock::Text(TextContent {
content_type: TextContentType::Text,
text: pending,
text_signature: None,
}));
}
result
}
const NON_VISION_USER_IMAGE_PLACEHOLDER: &str =
"(image omitted: model does not support images)";
const NON_VISION_TOOL_IMAGE_PLACEHOLDER: &str =
"(tool image omitted: model does not support images)";
fn replace_images_with_placeholder(
blocks: &[ContentBlock],
placeholder: &str,
) -> Vec<ContentBlock> {
let mut result = Vec::with_capacity(blocks.len());
let mut prev_was_placeholder = false;
for block in blocks {
if matches!(block, ContentBlock::Image(_)) {
if !prev_was_placeholder {
result.push(ContentBlock::Text(TextContent::new(placeholder)));
}
prev_was_placeholder = true;
continue;
}
result.push(block.clone());
prev_was_placeholder = matches!(block, ContentBlock::Text(t) if t.text == placeholder);
}
result
}
fn downgrade_unsupported_images(messages: &[Message], model: &Model) -> Vec<Message> {
if model.input.contains(&InputModality::Image) {
return messages.to_vec();
}
messages
.iter()
.map(|msg| match msg {
Message::User(u) => match &u.content {
MessageContent::Blocks(blocks) => {
let replaced =
replace_images_with_placeholder(blocks, NON_VISION_USER_IMAGE_PLACEHOLDER);
Message::User(crate::UserMessage {
role: u.role,
content: MessageContent::Blocks(replaced),
timestamp: u.timestamp,
})
}
_ => msg.clone(),
},
Message::ToolResult(t) => {
let replaced =
replace_images_with_placeholder(&t.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER);
Message::ToolResult(ToolResultMessage {
role: t.role,
tool_call_id: t.tool_call_id.clone(),
tool_name: t.tool_name.clone(),
content: replaced,
details: t.details.clone(),
is_error: t.is_error,
timestamp: t.timestamp,
})
}
_ => msg.clone(),
})
.collect()
}
pub fn normalize_tool_call_id(id: &str) -> String {
let sanitized: String = id
.chars()
.map(|c| if c.is_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
.collect();
if sanitized.len() > 64 {
sanitized[..64].trim_end_matches('_').to_string()
} else {
sanitized.trim_end_matches('_').to_string()
}
}
pub fn transform_messages_for_model(messages: &[Message], model: &Model) -> Vec<Message> {
let image_aware = downgrade_unsupported_images(messages, model);
let mut tool_call_id_map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let transformed: Vec<Message> = image_aware
.iter()
.map(|msg| match msg {
Message::User(_) => msg.clone(),
Message::ToolResult(t) => {
if let Some(normalized) = tool_call_id_map.get(&t.tool_call_id) {
if normalized != &t.tool_call_id {
return Message::ToolResult(ToolResultMessage {
tool_call_id: normalized.clone(),
..t.clone()
});
}
}
msg.clone()
}
Message::Assistant(a) => {
let is_same_model = a.provider == model.provider
&& a.api == model.api
&& a.model == model.id;
let new_content: Vec<ContentBlock> = a
.content
.iter()
.flat_map(|block| match block {
ContentBlock::Thinking(th) => {
if th.redacted == Some(true) && !is_same_model {
return vec![];
}
if is_same_model && th.thinking_signature.is_some() {
return vec![block.clone()];
}
if th.thinking.trim().is_empty() {
return vec![];
}
if is_same_model {
return vec![block.clone()];
}
vec![ContentBlock::Text(TextContent::new(&th.thinking))]
}
ContentBlock::Text(_) => vec![block.clone()],
ContentBlock::ToolCall(tc) => {
let mut new_tc = tc.clone();
if !is_same_model && tc.thought_signature.is_some() {
new_tc.thought_signature = None;
}
if !is_same_model {
let normalized = normalize_tool_call_id(&tc.id);
if normalized != tc.id {
tool_call_id_map
.insert(tc.id.clone(), normalized.clone());
new_tc.id = normalized;
}
}
vec![ContentBlock::ToolCall(new_tc)]
}
_ => vec![block.clone()],
})
.collect();
Message::Assistant(AssistantMessage {
content: new_content,
..a.clone()
})
}
})
.collect();
let mut result: Vec<Message> = Vec::with_capacity(transformed.len());
let mut pending_tool_calls: Vec<ToolCall> = Vec::new();
let mut existing_tool_result_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
let insert_synthetic_results =
|pending: &mut Vec<ToolCall>,
existing: &mut std::collections::HashSet<String>,
out: &mut Vec<Message>| {
for tc in pending.drain(..) {
if !existing.contains(&tc.id) {
out.push(Message::ToolResult(ToolResultMessage {
role: crate::ToolResultRole::ToolResult,
tool_call_id: tc.id.clone(),
tool_name: tc.name.clone(),
content: vec![ContentBlock::Text(TextContent::new(
"No result provided",
))],
details: None,
is_error: true,
timestamp: chrono::Utc::now().timestamp_millis(),
}));
}
}
existing.clear();
};
for msg in &transformed {
match msg {
Message::Assistant(a) => {
insert_synthetic_results(
&mut pending_tool_calls,
&mut existing_tool_result_ids,
&mut result,
);
if a.stop_reason == StopReason::Error || a.stop_reason == StopReason::Aborted {
continue;
}
let tool_calls: Vec<&ToolCall> = a
.content
.iter()
.filter_map(|b| b.as_tool_call())
.collect();
if !tool_calls.is_empty() {
pending_tool_calls = tool_calls.into_iter().cloned().collect();
existing_tool_result_ids.clear();
}
result.push(msg.clone());
}
Message::ToolResult(t) => {
existing_tool_result_ids.insert(t.tool_call_id.clone());
result.push(msg.clone());
}
Message::User(_) => {
insert_synthetic_results(
&mut pending_tool_calls,
&mut existing_tool_result_ids,
&mut result,
);
result.push(msg.clone());
}
}
}
insert_synthetic_results(
&mut pending_tool_calls,
&mut existing_tool_result_ids,
&mut result,
);
result
}
pub fn anthropic_to_openai(messages: &[Message]) -> Vec<Message> {
transform_messages(
messages,
Api::AnthropicMessages,
Api::OpenAiCompletions,
TransformOptions::default(),
)
}
pub fn openai_to_anthropic(messages: &[Message]) -> Vec<Message> {
transform_messages(
messages,
Api::OpenAiCompletions,
Api::AnthropicMessages,
TransformOptions::default(),
)
}
pub fn google_to_openai(messages: &[Message]) -> Vec<Message> {
transform_messages(
messages,
Api::GoogleGenerativeAi,
Api::OpenAiCompletions,
TransformOptions::default(),
)
}
pub fn anthropic_to_google(messages: &[Message]) -> Vec<Message> {
transform_messages(
messages,
Api::AnthropicMessages,
Api::GoogleGenerativeAi,
TransformOptions::default(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UserMessage;
fn user_msg(text: &str) -> Message {
Message::User(UserMessage::new(text))
}
fn assistant_msg(api: Api, provider: &str, model: &str, blocks: Vec<ContentBlock>) -> Message {
let mut msg = AssistantMessage::new(api, provider, model);
msg.content = blocks;
Message::Assistant(msg)
}
fn tool_result_msg(tool_call_id: &str, tool_name: &str, text: &str) -> Message {
Message::ToolResult(ToolResultMessage::new(
tool_call_id,
tool_name,
vec![ContentBlock::Text(TextContent::new(text))],
))
}
#[test]
fn test_anthropic_to_openai_text() {
let msgs = vec![
user_msg("Hello"),
assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![ContentBlock::Text(TextContent::new("Hi there!"))],
),
];
let result = anthropic_to_openai(&msgs);
assert_eq!(result.len(), 2);
match &result[0] {
Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
_ => panic!("Expected User message"),
}
match &result[1] {
Message::Assistant(a) => {
assert_eq!(a.api, Api::OpenAiCompletions);
assert_eq!(a.text_content(), "Hi there!");
}
_ => panic!("Expected Assistant message"),
}
}
#[test]
fn test_thinking_block_anthropic_to_openai() {
let msgs = vec![assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![
ContentBlock::Thinking(ThinkingContent::new("Let me think...")),
ContentBlock::Text(TextContent::new("Here's the answer.")),
],
)];
let result = anthropic_to_openai(&msgs);
match &result[0] {
Message::Assistant(a) => {
let text = a.text_content();
assert!(text.contains("<thinking>"));
assert!(text.contains("Let me think..."));
assert!(text.contains("Here's the answer."));
assert!(!a
.content
.iter()
.any(|b| matches!(b, ContentBlock::Thinking(_))));
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_thinking_block_stripped() {
let msgs = vec![assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![
ContentBlock::Thinking(ThinkingContent::new("Internal thought")),
ContentBlock::Text(TextContent::new("Final answer.")),
],
)];
let opts = TransformOptions {
strip_thinking: true,
..Default::default()
};
let result =
transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
match &result[0] {
Message::Assistant(a) => {
assert_eq!(a.content.len(), 1);
assert_eq!(a.text_content(), "Final answer.");
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_tool_calls_preserved() {
let tool_call = ContentBlock::ToolCall(ToolCall::new(
"call_123",
"get_weather",
serde_json::json!({"city": "Tokyo"}),
));
let msgs = vec![
assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![
ContentBlock::Text(TextContent::new("Let me check.")),
tool_call,
],
),
tool_result_msg("call_123", "get_weather", "Sunny, 22°C"),
];
let result = anthropic_to_openai(&msgs);
match &result[0] {
Message::Assistant(a) => {
let tc = a.content.iter().find_map(|b| b.as_tool_call());
assert!(tc.is_some(), "Tool call should be preserved");
let tc = tc.unwrap();
assert_eq!(tc.id, "call_123");
assert_eq!(tc.name, "get_weather");
}
_ => panic!("Expected Assistant"),
}
match &result[1] {
Message::ToolResult(t) => {
assert_eq!(t.tool_call_id, "call_123");
assert_eq!(t.tool_name, "get_weather");
}
_ => panic!("Expected ToolResult"),
}
}
#[test]
fn test_tool_calls_dropped_with_option() {
let msgs = vec![assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![
ContentBlock::Text(TextContent::new("I will call a tool.")),
ContentBlock::ToolCall(ToolCall::new("tc_1", "search", serde_json::json!({}))),
],
)];
let opts = TransformOptions {
convert_tools: false,
..Default::default()
};
let result =
transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
match &result[0] {
Message::Assistant(a) => {
assert_eq!(a.content.len(), 1);
assert_eq!(a.text_content(), "I will call a tool.");
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_image_block_conversion() {
let msgs = vec![assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![
ContentBlock::Text(TextContent::new("Here's the image:")),
ContentBlock::Image(ImageContent::new("iVBORw0KGgo=", "image/png")),
],
)];
let result = anthropic_to_openai(&msgs);
match &result[0] {
Message::Assistant(a) => {
let has_text = a.content.iter().any(|b| matches!(b, ContentBlock::Text(_)));
let has_image = a
.content
.iter()
.any(|b| matches!(b, ContentBlock::Image(_)));
assert!(has_text, "Text block should be preserved");
assert!(has_image, "Image block should be preserved");
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_openai_to_anthropic_roundtrip() {
let original = vec![
user_msg("What is 2+2?"),
assistant_msg(
Api::OpenAiCompletions,
"openai",
"gpt-4o",
vec![ContentBlock::Text(TextContent::new("The answer is 4."))],
),
];
let to_anthropic = openai_to_anthropic(&original);
let back_to_openai = anthropic_to_openai(&to_anthropic);
match (&original[1], &back_to_openai[1]) {
(Message::Assistant(orig), Message::Assistant(rt)) => {
assert_eq!(orig.text_content(), rt.text_content());
}
_ => panic!("Expected Assistant messages"),
}
}
#[test]
fn test_google_to_openai() {
let msgs = vec![
user_msg("Summarize this"),
assistant_msg(
Api::GoogleGenerativeAi,
"google",
"gemini-2.0-flash",
vec![ContentBlock::Text(TextContent::new("Here's a summary."))],
),
];
let result = google_to_openai(&msgs);
assert_eq!(result.len(), 2);
match &result[1] {
Message::Assistant(a) => {
assert_eq!(a.api, Api::OpenAiCompletions);
assert_eq!(a.text_content(), "Here's a summary.");
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_same_api_noop() {
let msgs = vec![user_msg("Hello")];
let result = transform_messages(
&msgs,
Api::AnthropicMessages,
Api::AnthropicMessages,
TransformOptions::default(),
);
assert_eq!(result.len(), 1);
match &result[0] {
Message::User(u) => assert_eq!(u.content.as_str(), Some("Hello")),
_ => panic!("Expected User"),
}
}
#[test]
fn test_thinking_preserved_for_anthropic_target() {
let msgs = vec![assistant_msg(
Api::OpenAiCompletions,
"openai",
"gpt-4o",
vec![
ContentBlock::Thinking(ThinkingContent::new("Reasoning...")),
ContentBlock::Text(TextContent::new("Answer.")),
],
)];
let result = openai_to_anthropic(&msgs);
match &result[0] {
Message::Assistant(a) => {
let has_thinking = a
.content
.iter()
.any(|b| matches!(b, ContentBlock::Thinking(_)));
assert!(
has_thinking,
"Thinking block should be preserved for Anthropic"
);
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_anthropic_to_google_thinking() {
let msgs = vec![assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![
ContentBlock::Thinking(ThinkingContent::new("Deep thought")),
ContentBlock::Text(TextContent::new("Result.")),
],
)];
let result = anthropic_to_google(&msgs);
match &result[0] {
Message::Assistant(a) => {
let has_thinking = a
.content
.iter()
.any(|b| matches!(b, ContentBlock::Thinking(_)));
assert!(
!has_thinking,
"Google target should not have thinking blocks"
);
let text = a.text_content();
assert!(text.contains("<thinking>"));
assert!(text.contains("Deep thought"));
assert!(text.contains("Result."));
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_full_conversation_mixed_blocks() {
let msgs = vec![
user_msg("What's the weather in Paris?"),
assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![
ContentBlock::Thinking(ThinkingContent::new("User wants weather.")),
ContentBlock::Text(TextContent::new("Let me check.")),
ContentBlock::ToolCall(ToolCall::new(
"tc_001",
"get_weather",
serde_json::json!({"location": "Paris"}),
)),
],
),
tool_result_msg("tc_001", "get_weather", "Rainy, 15°C"),
assistant_msg(
Api::AnthropicMessages,
"anthropic",
"claude-3.5-sonnet",
vec![ContentBlock::Text(TextContent::new(
"It's rainy and 15°C in Paris.",
))],
),
];
let result = anthropic_to_openai(&msgs);
assert_eq!(result.len(), 4, "All 4 messages should be preserved");
match &result[1] {
Message::Assistant(a) => {
let has_tool = a
.content
.iter()
.any(|b| matches!(b, ContentBlock::ToolCall(_)));
assert!(has_tool, "Tool call should be preserved");
let has_thinking = a
.content
.iter()
.any(|b| matches!(b, ContentBlock::Thinking(_)));
assert!(
!has_thinking,
"Thinking should be converted to text for OpenAI"
);
}
_ => panic!("Expected Assistant"),
}
match &result[2] {
Message::ToolResult(t) => {
assert_eq!(t.tool_call_id, "tc_001");
}
_ => panic!("Expected ToolResult"),
}
match &result[3] {
Message::Assistant(a) => {
assert_eq!(a.text_content(), "It's rainy and 15°C in Paris.");
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_images_dropped_with_option() {
let msgs = vec![Message::User(UserMessage::new(vec![
ContentBlock::Text(TextContent::new("Describe this:")),
ContentBlock::Image(ImageContent::new("AAAA", "image/jpeg")),
]))];
let opts = TransformOptions {
convert_images: false,
..Default::default()
};
let result =
transform_messages(&msgs, Api::AnthropicMessages, Api::OpenAiCompletions, opts);
match &result[0] {
Message::User(u) => match &u.content {
MessageContent::Blocks(blocks) => {
let has_image = blocks.iter().any(|b| matches!(b, ContentBlock::Image(_)));
assert!(!has_image, "Image should be dropped");
assert_eq!(blocks.len(), 1);
}
_ => panic!("Expected blocks"),
},
_ => panic!("Expected User"),
}
}
#[test]
fn test_assistant_metadata_preserved() {
let mut a = AssistantMessage::new(Api::AnthropicMessages, "anthropic", "claude-3.5-sonnet");
a.content = vec![ContentBlock::Text(TextContent::new("Hi"))];
a.usage = Usage {
input: 100,
output: 50,
cache_read: 10,
cache_write: 5,
total_tokens: 165,
cost: Default::default(),
};
a.stop_reason = StopReason::Stop;
a.error_message = None;
a.response_id = Some("msg_abc123".to_string());
let original_ts = a.timestamp;
let msgs = vec![Message::Assistant(a)];
let result = anthropic_to_openai(&msgs);
match &result[0] {
Message::Assistant(a) => {
assert_eq!(a.usage.input, 100);
assert_eq!(a.usage.output, 50);
assert_eq!(a.stop_reason, StopReason::Stop);
assert_eq!(a.response_id, Some("msg_abc123".to_string()));
assert_eq!(a.timestamp, original_ts);
assert_eq!(a.api, Api::OpenAiCompletions);
}
_ => panic!("Expected Assistant"),
}
}
#[test]
fn test_error_tool_result_preserved() {
let err = ToolResultMessage::error("tc_err", "failing_tool", "Something went wrong");
let msgs = vec![Message::ToolResult(err)];
let result = anthropic_to_openai(&msgs);
match &result[0] {
Message::ToolResult(t) => {
assert!(t.is_error);
assert_eq!(t.tool_call_id, "tc_err");
assert_eq!(t.tool_name, "failing_tool");
}
_ => panic!("Expected ToolResult"),
}
}
}