use serde::{Deserialize, Serialize};
use serde_json::Value;
use ironclad_core::{ApiFormat, IroncladError, Result};
fn saturating_u32(v: u64) -> u32 {
v.min(u32::MAX as u64) as u32
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedRequest {
pub model: String,
pub messages: Vec<UnifiedMessage>,
pub max_tokens: Option<u32>,
pub temperature: Option<f64>,
pub system: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quality_target: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum ContentPart {
Text { text: String },
ImageUrl { url: String, detail: Option<String> },
ImageBase64 { media_type: String, data: String },
AudioTranscription { text: String, source: String },
}
impl ContentPart {
pub fn text(s: &str) -> Self {
ContentPart::Text {
text: s.to_string(),
}
}
pub fn image_url(url: &str) -> Self {
ContentPart::ImageUrl {
url: url.to_string(),
detail: None,
}
}
pub fn image_base64(media_type: &str, data: &str) -> Self {
ContentPart::ImageBase64 {
media_type: media_type.to_string(),
data: data.to_string(),
}
}
pub fn audio_transcription(text: &str, source: &str) -> Self {
ContentPart::AudioTranscription {
text: text.to_string(),
source: source.to_string(),
}
}
pub fn to_text(&self) -> String {
match self {
ContentPart::Text { text } => text.clone(),
ContentPart::ImageUrl { url, .. } => format!("[Image: {url}]"),
ContentPart::ImageBase64 { media_type, .. } => format!("[Image: {media_type}]"),
ContentPart::AudioTranscription { text, source } => {
format!("[Audio from {source}]: {text}")
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UnifiedMessage {
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parts: Option<Vec<ContentPart>>,
}
impl UnifiedMessage {
pub fn text(role: &str, content: &str) -> Self {
Self {
role: role.to_string(),
content: content.to_string(),
parts: None,
}
}
pub fn multimodal(role: &str, parts: Vec<ContentPart>) -> Self {
let text_content = parts
.iter()
.map(|p| p.to_text())
.collect::<Vec<_>>()
.join("\n");
Self {
role: role.to_string(),
content: text_content,
parts: Some(parts),
}
}
pub fn is_multimodal(&self) -> bool {
self.parts.as_ref().is_some_and(|p| {
p.iter()
.any(|part| !matches!(part, ContentPart::Text { .. }))
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedResponse {
pub content: String,
pub model: String,
pub tokens_in: u32,
pub tokens_out: u32,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamChunk {
pub delta: String,
pub model: Option<String>,
pub finish_reason: Option<String>,
pub tokens_in: Option<u32>,
pub tokens_out: Option<u32>,
}
#[derive(Debug, Default)]
pub struct StreamAccumulator {
content: String,
model: Option<String>,
tokens_in: u32,
tokens_out: u32,
finish_reason: Option<String>,
}
impl StreamAccumulator {
pub fn push(&mut self, chunk: &StreamChunk) {
self.content.push_str(&chunk.delta);
if let Some(ref m) = chunk.model {
self.model = Some(m.clone());
}
if let Some(t) = chunk.tokens_in {
self.tokens_in = t;
}
if let Some(t) = chunk.tokens_out {
self.tokens_out = t;
}
if chunk.finish_reason.is_some() {
self.finish_reason = chunk.finish_reason.clone();
}
}
pub fn finalize(self) -> UnifiedResponse {
UnifiedResponse {
content: self.content,
model: self.model.unwrap_or_default(),
tokens_in: self.tokens_in,
tokens_out: self.tokens_out,
finish_reason: self.finish_reason,
}
}
}
pub fn parse_sse_chunk(data: &str, format: &ApiFormat) -> Option<StreamChunk> {
let data = data.strip_prefix("data: ")?.trim();
if data == "[DONE]" {
return None;
}
let json: Value = serde_json::from_str(data).ok()?;
match format {
ApiFormat::OpenAiCompletions | ApiFormat::OpenAiResponses => {
let choice = json.get("choices")?.get(0)?;
let delta = choice.get("delta")?;
Some(StreamChunk {
delta: delta
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
model: json
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
finish_reason: choice
.get("finish_reason")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
tokens_in: json
.get("usage")
.and_then(|u| u.get("prompt_tokens"))
.and_then(|v| v.as_u64())
.map(saturating_u32),
tokens_out: json
.get("usage")
.and_then(|u| u.get("completion_tokens"))
.and_then(|v| v.as_u64())
.map(saturating_u32),
})
}
ApiFormat::AnthropicMessages => {
let delta = json.get("delta")?;
Some(StreamChunk {
delta: delta
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
model: None,
finish_reason: delta
.get("stop_reason")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
tokens_in: json
.get("usage")
.and_then(|u| u.get("input_tokens"))
.and_then(|v| v.as_u64())
.map(saturating_u32),
tokens_out: json
.get("usage")
.and_then(|u| u.get("output_tokens"))
.and_then(|v| v.as_u64())
.map(saturating_u32),
})
}
ApiFormat::GoogleGenerativeAi => {
let candidate = json.get("candidates")?.get(0)?;
let content = candidate.get("content")?;
let parts = content.get("parts")?.get(0)?;
Some(StreamChunk {
delta: parts
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
model: None,
finish_reason: candidate
.get("finishReason")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
tokens_in: json
.get("usageMetadata")
.and_then(|u| u.get("promptTokenCount"))
.and_then(|v| v.as_u64())
.map(saturating_u32),
tokens_out: json
.get("usageMetadata")
.and_then(|u| u.get("candidatesTokenCount"))
.and_then(|v| v.as_u64())
.map(saturating_u32),
})
}
}
}
pub fn translate_request(request: &UnifiedRequest, format: ApiFormat) -> Result<Value> {
match format {
ApiFormat::AnthropicMessages => translate_anthropic(request),
ApiFormat::OpenAiCompletions => translate_openai_completions(request),
ApiFormat::OpenAiResponses => translate_openai_responses(request),
ApiFormat::GoogleGenerativeAi => translate_google(request),
}
}
fn message_has_provider_content(message: &UnifiedMessage) -> bool {
if let Some(parts) = &message.parts
&& parts.iter().any(|p| match p {
ContentPart::Text { text } => !text.trim().is_empty(),
ContentPart::ImageUrl { .. }
| ContentPart::ImageBase64 { .. }
| ContentPart::AudioTranscription { .. } => true,
})
{
return true;
}
!message.content.trim().is_empty()
}
fn include_message_for_provider(message: &UnifiedMessage) -> bool {
if message.role == "assistant" && !message_has_provider_content(message) {
return false;
}
true
}
fn translate_anthropic(req: &UnifiedRequest) -> Result<Value> {
let messages: Vec<Value> = req
.messages
.iter()
.filter(|m| m.role != "system")
.filter(|m| include_message_for_provider(m))
.map(|m| {
let content = match &m.parts {
Some(parts) if m.is_multimodal() => parts_to_anthropic(parts),
_ => serde_json::json!(m.content),
};
serde_json::json!({
"role": m.role,
"content": content,
})
})
.collect();
let mut body = serde_json::json!({
"model": req.model,
"messages": messages,
});
if let Some(max) = req.max_tokens {
body["max_tokens"] = serde_json::json!(max);
}
if let Some(ref sys) = req.system {
body["system"] = serde_json::json!(sys);
} else {
let sys_msg = req.messages.iter().find(|m| m.role == "system");
if let Some(s) = sys_msg {
body["system"] = serde_json::json!(s.content);
}
}
if !req.tools.is_empty() {
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"description": t.description,
"input_schema": t.parameters,
})
})
.collect();
body["tools"] = serde_json::json!(tools);
}
Ok(body)
}
fn translate_openai_completions(req: &UnifiedRequest) -> Result<Value> {
let mut messages: Vec<Value> = Vec::new();
if let Some(ref sys) = req.system {
messages.push(serde_json::json!({
"role": "system",
"content": sys,
}));
}
for m in &req.messages {
if !include_message_for_provider(m) {
continue;
}
let content = match &m.parts {
Some(parts) if m.is_multimodal() => parts_to_openai(parts),
_ => serde_json::json!(m.content),
};
messages.push(serde_json::json!({
"role": m.role,
"content": content,
}));
}
let mut body = serde_json::json!({
"model": req.model,
"messages": messages,
});
if let Some(max) = req.max_tokens {
body["max_tokens"] = serde_json::json!(max);
}
if let Some(temp) = req.temperature {
body["temperature"] = serde_json::json!(temp);
}
if !req.tools.is_empty() {
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| {
serde_json::json!({
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
}
})
})
.collect();
body["tools"] = serde_json::json!(tools);
}
Ok(body)
}
fn translate_openai_responses(req: &UnifiedRequest) -> Result<Value> {
let mut input_items: Vec<Value> = Vec::new();
if let Some(sys) = &req.system {
input_items.push(serde_json::json!({
"role": "system",
"content": [{"type": "input_text", "text": sys}],
}));
}
for m in &req.messages {
if !include_message_for_provider(m) {
continue;
}
let content = match &m.parts {
Some(parts) if m.is_multimodal() => parts_to_openai_responses(parts),
_ => vec![serde_json::json!({"type": "input_text", "text": m.content})],
};
input_items.push(serde_json::json!({
"role": m.role,
"content": content,
}));
}
let mut body = serde_json::json!({
"model": req.model,
"input": input_items,
});
if let Some(max) = req.max_tokens {
body["max_output_tokens"] = serde_json::json!(max);
}
if let Some(temp) = req.temperature {
body["temperature"] = serde_json::json!(temp);
}
if !req.tools.is_empty() {
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| {
serde_json::json!({
"type": "function",
"name": t.name,
"description": t.description,
"parameters": t.parameters,
})
})
.collect();
body["tools"] = serde_json::json!(tools);
}
Ok(body)
}
fn translate_google(req: &UnifiedRequest) -> Result<Value> {
let mut system_lines = Vec::new();
if let Some(sys) = &req.system {
system_lines.push(sys.clone());
}
system_lines.extend(
req.messages
.iter()
.filter(|m| m.role == "system")
.map(|m| m.content.clone()),
);
let contents: Vec<Value> = req
.messages
.iter()
.filter(|m| m.role != "system")
.filter(|m| include_message_for_provider(m))
.map(|m| {
let role = match m.role.as_str() {
"assistant" => "model",
"user" => "user",
_ => "user",
};
let parts = match &m.parts {
Some(parts) if m.is_multimodal() => parts_to_google(parts),
_ => vec![serde_json::json!({ "text": m.content })],
};
serde_json::json!({
"role": role,
"parts": parts,
})
})
.collect();
let mut gen_config = serde_json::Map::new();
if let Some(max) = req.max_tokens {
gen_config.insert("maxOutputTokens".into(), serde_json::json!(max));
}
if let Some(temp) = req.temperature {
gen_config.insert("temperature".into(), serde_json::json!(temp));
}
let mut body = serde_json::json!({
"contents": contents,
"generationConfig": gen_config,
});
if !system_lines.is_empty() {
body["systemInstruction"] = serde_json::json!({
"parts": [{"text": system_lines.join("\n")}],
});
}
if !req.tools.is_empty() {
let declarations: Vec<Value> = req
.tools
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"description": t.description,
"parameters": t.parameters,
})
})
.collect();
body["tools"] = serde_json::json!([{
"functionDeclarations": declarations
}]);
}
Ok(body)
}
pub fn parts_to_openai(parts: &[ContentPart]) -> Value {
let blocks: Vec<Value> = parts
.iter()
.map(|p| match p {
ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
ContentPart::ImageUrl { url, detail } => serde_json::json!({
"type": "image_url",
"image_url": {
"url": url,
"detail": detail.as_deref().unwrap_or("auto"),
}
}),
ContentPart::ImageBase64 { media_type, data } => serde_json::json!({
"type": "image_url",
"image_url": {
"url": format!("data:{media_type};base64,{data}"),
}
}),
ContentPart::AudioTranscription { text, .. } => serde_json::json!({
"type": "text",
"text": text,
}),
})
.collect();
Value::Array(blocks)
}
pub fn parts_to_anthropic(parts: &[ContentPart]) -> Value {
let blocks: Vec<Value> = parts
.iter()
.map(|p| match p {
ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
ContentPart::ImageUrl { url, .. } => serde_json::json!({
"type": "image",
"source": {
"type": "url",
"url": url,
}
}),
ContentPart::ImageBase64 { media_type, data } => serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
}
}),
ContentPart::AudioTranscription { text, .. } => serde_json::json!({
"type": "text",
"text": text,
}),
})
.collect();
Value::Array(blocks)
}
fn parts_to_openai_responses(parts: &[ContentPart]) -> Vec<Value> {
parts
.iter()
.map(|p| match p {
ContentPart::Text { text } => serde_json::json!({
"type": "input_text",
"text": text,
}),
ContentPart::ImageUrl { url, detail } => serde_json::json!({
"type": "input_image",
"image_url": url,
"detail": detail.as_deref().unwrap_or("auto"),
}),
ContentPart::ImageBase64 { media_type, data } => serde_json::json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}"),
"detail": "auto",
}),
ContentPart::AudioTranscription { text, .. } => serde_json::json!({
"type": "input_text",
"text": text,
}),
})
.collect()
}
fn parts_to_google(parts: &[ContentPart]) -> Vec<Value> {
parts
.iter()
.map(|p| match p {
ContentPart::Text { text } => serde_json::json!({ "text": text }),
ContentPart::ImageUrl { url, .. } => serde_json::json!({
"fileData": {
"mimeType": "image/*",
"fileUri": url,
}
}),
ContentPart::ImageBase64 { media_type, data } => serde_json::json!({
"inlineData": {
"mimeType": media_type,
"data": data,
}
}),
ContentPart::AudioTranscription { text, .. } => serde_json::json!({ "text": text }),
})
.collect()
}
pub fn translate_response(body: &Value, format: ApiFormat) -> Result<UnifiedResponse> {
match format {
ApiFormat::AnthropicMessages => parse_anthropic_response(body),
ApiFormat::OpenAiCompletions => parse_openai_completions_response(body),
ApiFormat::OpenAiResponses => parse_openai_responses_response(body),
ApiFormat::GoogleGenerativeAi => parse_google_response(body),
}
}
fn parse_anthropic_response(body: &Value) -> Result<UnifiedResponse> {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_call_parts: Vec<String> = Vec::new();
if let Some(blocks) = body["content"].as_array() {
for block in blocks {
match block["type"].as_str() {
Some("text") => {
if let Some(t) = block["text"].as_str() {
text_parts.push(t.to_string());
}
}
Some("tool_use") => {
let name = block["name"].as_str().unwrap_or("unknown");
let input = &block["input"];
let tc = serde_json::json!({"tool_call": {"name": name, "params": input}});
tool_call_parts.push(tc.to_string());
}
_ => {}
}
}
}
let mut content = text_parts.join("\n");
for tc in tool_call_parts {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&tc);
}
let model = body["model"].as_str().unwrap_or("unknown").to_string();
let tokens_in = saturating_u32(body["usage"]["input_tokens"].as_u64().unwrap_or(0));
let tokens_out = saturating_u32(body["usage"]["output_tokens"].as_u64().unwrap_or(0));
let finish_reason = body["stop_reason"].as_str().map(String::from);
Ok(UnifiedResponse {
content,
model,
tokens_in,
tokens_out,
finish_reason,
})
}
fn parse_openai_completions_response(body: &Value) -> Result<UnifiedResponse> {
let choice = body["choices"]
.as_array()
.and_then(|arr| arr.first())
.ok_or_else(|| IroncladError::Llm("no choices in OpenAI response".into()))?;
let mut content = choice["message"]["content"]
.as_str()
.unwrap_or("")
.to_string();
if let Some(tool_calls) = choice["message"]["tool_calls"].as_array() {
for tc in tool_calls {
if let Some(func) = tc.get("function") {
let name = func["name"].as_str().unwrap_or("unknown");
let args: Value = func["arguments"]
.as_str()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(serde_json::json!({}));
let tc_text = serde_json::json!({"tool_call": {"name": name, "params": args}});
if !content.is_empty() {
content.push('\n');
}
content.push_str(&tc_text.to_string());
}
}
}
let model = body["model"].as_str().unwrap_or("unknown").to_string();
let tokens_in = saturating_u32(body["usage"]["prompt_tokens"].as_u64().unwrap_or(0));
let tokens_out = saturating_u32(body["usage"]["completion_tokens"].as_u64().unwrap_or(0));
let finish_reason = choice["finish_reason"].as_str().map(String::from);
Ok(UnifiedResponse {
content,
model,
tokens_in,
tokens_out,
finish_reason,
})
}
fn parse_openai_responses_response(body: &Value) -> Result<UnifiedResponse> {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_call_parts: Vec<String> = Vec::new();
if let Some(output_items) = body["output"].as_array() {
for item in output_items {
match item["type"].as_str() {
Some("message") => {
if let Some(content_arr) = item["content"].as_array() {
for part in content_arr {
if part["type"].as_str() == Some("output_text")
&& let Some(t) = part["text"].as_str()
{
text_parts.push(t.to_string());
}
}
}
}
Some("function_call") => {
let name = item["name"].as_str().unwrap_or("unknown");
let args: Value = item["arguments"]
.as_str()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(serde_json::json!({}));
let tc = serde_json::json!({"tool_call": {"name": name, "params": args}});
tool_call_parts.push(tc.to_string());
}
_ => {}
}
}
}
let mut content = text_parts.join("\n");
for tc in tool_call_parts {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&tc);
}
let model = body["model"].as_str().unwrap_or("unknown").to_string();
let tokens_in = saturating_u32(body["usage"]["input_tokens"].as_u64().unwrap_or(0));
let tokens_out = saturating_u32(body["usage"]["output_tokens"].as_u64().unwrap_or(0));
Ok(UnifiedResponse {
content,
model,
tokens_in,
tokens_out,
finish_reason: None,
})
}
fn parse_google_response(body: &Value) -> Result<UnifiedResponse> {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_call_parts: Vec<String> = Vec::new();
if let Some(parts) = body["candidates"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|c| c["content"]["parts"].as_array())
{
for part in parts {
if let Some(t) = part["text"].as_str() {
text_parts.push(t.to_string());
}
if let Some(fc) = part.get("functionCall") {
let name = fc["name"].as_str().unwrap_or("unknown");
let args = fc.get("args").cloned().unwrap_or(serde_json::json!({}));
let tc = serde_json::json!({"tool_call": {"name": name, "params": args}});
tool_call_parts.push(tc.to_string());
}
}
}
let mut content = text_parts.join("\n");
for tc in tool_call_parts {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&tc);
}
let model = body["modelVersion"]
.as_str()
.unwrap_or("unknown")
.to_string();
let tokens_in = saturating_u32(
body["usageMetadata"]["promptTokenCount"]
.as_u64()
.unwrap_or(0),
);
let tokens_out = saturating_u32(
body["usageMetadata"]["candidatesTokenCount"]
.as_u64()
.unwrap_or(0),
);
let finish_reason = body["candidates"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|c| c["finishReason"].as_str())
.map(String::from);
Ok(UnifiedResponse {
content,
model,
tokens_in,
tokens_out,
finish_reason,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_request() -> UnifiedRequest {
UnifiedRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![
UnifiedMessage {
role: "user".into(),
content: "Hello".into(),
parts: None,
},
UnifiedMessage {
role: "assistant".into(),
content: "Hi there".into(),
parts: None,
},
UnifiedMessage {
role: "user".into(),
content: "How are you?".into(),
parts: None,
},
],
max_tokens: Some(1024),
temperature: Some(0.7),
system: Some("You are helpful.".into()),
quality_target: None,
tools: vec![],
}
}
#[test]
fn translate_request_anthropic() {
let req = sample_request();
let body = translate_request(&req, ApiFormat::AnthropicMessages).unwrap();
assert_eq!(body["model"], "claude-sonnet-4-20250514");
assert_eq!(body["system"], "You are helpful.");
assert_eq!(body["max_tokens"], 1024);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[0]["role"], "user");
}
#[test]
fn translate_request_openai_completions() {
let req = sample_request();
let body = translate_request(&req, ApiFormat::OpenAiCompletions).unwrap();
assert_eq!(body["model"], "claude-sonnet-4-20250514");
assert_eq!(body["temperature"], 0.7);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs[0]["role"], "system");
assert_eq!(msgs[0]["content"], "You are helpful.");
assert_eq!(msgs.len(), 4); }
#[test]
fn translate_request_openai_responses() {
let req = sample_request();
let body = translate_request(&req, ApiFormat::OpenAiResponses).unwrap();
assert_eq!(body["model"], "claude-sonnet-4-20250514");
let input = body["input"].as_array().unwrap();
assert_eq!(input[0]["role"], "system");
assert_eq!(input[1]["role"], "user");
assert_eq!(input[1]["content"][0]["type"], "input_text");
assert_eq!(input[1]["content"][0]["text"], "Hello");
assert_eq!(body["max_output_tokens"], 1024);
assert_eq!(body["temperature"], 0.7);
}
#[test]
fn translate_request_google() {
let req = sample_request();
let body = translate_request(&req, ApiFormat::GoogleGenerativeAi).unwrap();
let contents = body["contents"].as_array().unwrap();
assert_eq!(contents[0]["role"], "user");
assert_eq!(contents[1]["role"], "model"); assert_eq!(body["generationConfig"]["maxOutputTokens"], 1024);
assert_eq!(body["generationConfig"]["temperature"], 0.7);
}
#[test]
fn translate_response_anthropic() {
let body = serde_json::json!({
"content": [{"type": "text", "text": "Hello from Claude"}],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let resp = translate_response(&body, ApiFormat::AnthropicMessages).unwrap();
assert_eq!(resp.content, "Hello from Claude");
assert_eq!(resp.model, "claude-sonnet-4-20250514");
assert_eq!(resp.tokens_in, 10);
assert_eq!(resp.tokens_out, 5);
assert_eq!(resp.finish_reason.as_deref(), Some("end_turn"));
}
#[test]
fn translate_response_openai_completions() {
let body = serde_json::json!({
"choices": [{
"message": {"role": "assistant", "content": "Hello from GPT"},
"finish_reason": "stop"
}],
"model": "gpt-4o",
"usage": {"prompt_tokens": 12, "completion_tokens": 8}
});
let resp = translate_response(&body, ApiFormat::OpenAiCompletions).unwrap();
assert_eq!(resp.content, "Hello from GPT");
assert_eq!(resp.tokens_in, 12);
assert_eq!(resp.tokens_out, 8);
assert_eq!(resp.finish_reason.as_deref(), Some("stop"));
}
#[test]
fn translate_response_openai_responses() {
let body = serde_json::json!({
"output": [{
"type": "message",
"content": [{"type": "output_text", "text": "Hello from Responses API"}]
}],
"model": "gpt-4o",
"usage": {"input_tokens": 15, "output_tokens": 10}
});
let resp = translate_response(&body, ApiFormat::OpenAiResponses).unwrap();
assert_eq!(resp.content, "Hello from Responses API");
assert_eq!(resp.tokens_in, 15);
assert_eq!(resp.tokens_out, 10);
}
#[test]
fn translate_response_google() {
let body = serde_json::json!({
"candidates": [{
"content": {
"parts": [{"text": "Hello from Gemini"}],
"role": "model"
},
"finishReason": "STOP"
}],
"modelVersion": "gemini-2.5-flash",
"usageMetadata": {"promptTokenCount": 20, "candidatesTokenCount": 6}
});
let resp = translate_response(&body, ApiFormat::GoogleGenerativeAi).unwrap();
assert_eq!(resp.content, "Hello from Gemini");
assert_eq!(resp.model, "gemini-2.5-flash");
assert_eq!(resp.tokens_in, 20);
assert_eq!(resp.tokens_out, 6);
assert_eq!(resp.finish_reason.as_deref(), Some("STOP"));
}
#[test]
fn stream_accumulator_empty() {
let acc = StreamAccumulator::default();
let resp = acc.finalize();
assert_eq!(resp.content, "");
assert_eq!(resp.model, "");
assert_eq!(resp.tokens_in, 0);
assert_eq!(resp.tokens_out, 0);
assert!(resp.finish_reason.is_none());
}
#[test]
fn stream_accumulator_pushes_deltas() {
let mut acc = StreamAccumulator::default();
for text in ["Hello", ", ", "world!"] {
acc.push(&StreamChunk {
delta: text.into(),
model: None,
finish_reason: None,
tokens_in: None,
tokens_out: None,
});
}
let resp = acc.finalize();
assert_eq!(resp.content, "Hello, world!");
}
#[test]
fn stream_accumulator_captures_model() {
let mut acc = StreamAccumulator::default();
acc.push(&StreamChunk {
delta: "hi".into(),
model: Some("gpt-4o".into()),
finish_reason: None,
tokens_in: None,
tokens_out: None,
});
let resp = acc.finalize();
assert_eq!(resp.model, "gpt-4o");
}
#[test]
fn stream_accumulator_captures_tokens_from_last() {
let mut acc = StreamAccumulator::default();
acc.push(&StreamChunk {
delta: "a".into(),
model: None,
finish_reason: None,
tokens_in: Some(5),
tokens_out: Some(1),
});
acc.push(&StreamChunk {
delta: "b".into(),
model: None,
finish_reason: Some("stop".into()),
tokens_in: Some(10),
tokens_out: Some(2),
});
let resp = acc.finalize();
assert_eq!(resp.tokens_in, 10);
assert_eq!(resp.tokens_out, 2);
assert_eq!(resp.finish_reason.as_deref(), Some("stop"));
}
#[test]
fn parse_sse_done_returns_none() {
let result = parse_sse_chunk("data: [DONE]", &ApiFormat::OpenAiCompletions);
assert!(result.is_none());
}
#[test]
fn parse_sse_openai_chunk() {
let line = r#"data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}],"model":"gpt-4o","usage":{"prompt_tokens":12,"completion_tokens":1}}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::OpenAiCompletions).unwrap();
assert_eq!(chunk.delta, "Hi");
assert_eq!(chunk.model.as_deref(), Some("gpt-4o"));
assert!(chunk.finish_reason.is_none());
assert_eq!(chunk.tokens_in, Some(12));
assert_eq!(chunk.tokens_out, Some(1));
}
#[test]
fn parse_sse_non_data_line_returns_none() {
let result = parse_sse_chunk("event: message", &ApiFormat::OpenAiCompletions);
assert!(result.is_none());
}
#[test]
fn content_part_text_to_text() {
let part = ContentPart::text("hello");
assert_eq!(part.to_text(), "hello");
}
#[test]
fn content_part_image_url_to_text() {
let part = ContentPart::image_url("https://example.com/img.png");
assert_eq!(part.to_text(), "[Image: https://example.com/img.png]");
}
#[test]
fn content_part_audio_to_text() {
let part = ContentPart::audio_transcription("Hello world", "whatsapp");
assert_eq!(part.to_text(), "[Audio from whatsapp]: Hello world");
}
#[test]
fn unified_message_text_helper() {
let msg = UnifiedMessage::text("user", "hi there");
assert_eq!(msg.role, "user");
assert_eq!(msg.content, "hi there");
assert!(msg.parts.is_none());
}
#[test]
fn unified_message_multimodal_helper() {
let msg = UnifiedMessage::multimodal(
"user",
vec![
ContentPart::text("Look at this:"),
ContentPart::image_url("https://example.com/photo.jpg"),
],
);
assert_eq!(msg.role, "user");
assert!(msg.parts.is_some());
assert_eq!(msg.parts.as_ref().unwrap().len(), 2);
assert!(msg.content.contains("Look at this:"));
assert!(
msg.content
.contains("[Image: https://example.com/photo.jpg]")
);
}
#[test]
fn unified_message_is_multimodal_false_for_text() {
let msg = UnifiedMessage::text("user", "plain text");
assert!(!msg.is_multimodal());
}
#[test]
fn unified_message_is_multimodal_true_with_image() {
let msg = UnifiedMessage::multimodal(
"user",
vec![
ContentPart::text("describe this"),
ContentPart::image_url("https://example.com/cat.jpg"),
],
);
assert!(msg.is_multimodal());
}
#[test]
fn parts_to_openai_text_only() {
let parts = vec![ContentPart::text("hello")];
let result = parts_to_openai(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[0]["text"], "hello");
}
#[test]
fn parts_to_openai_with_image() {
let parts = vec![
ContentPart::text("What is in this image?"),
ContentPart::image_url("https://example.com/img.png"),
];
let result = parts_to_openai(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[1]["type"], "image_url");
assert_eq!(arr[1]["image_url"]["url"], "https://example.com/img.png");
assert_eq!(arr[1]["image_url"]["detail"], "auto");
}
#[test]
fn parts_to_anthropic_base64_image() {
let parts = vec![ContentPart::image_base64("image/png", "iVBOR...")];
let result = parts_to_anthropic(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["type"], "image");
assert_eq!(arr[0]["source"]["type"], "base64");
assert_eq!(arr[0]["source"]["media_type"], "image/png");
assert_eq!(arr[0]["source"]["data"], "iVBOR...");
}
#[test]
fn parse_sse_anthropic_chunk() {
let line = r#"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"},"usage":{"input_tokens":10,"output_tokens":3}}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::AnthropicMessages).unwrap();
assert_eq!(chunk.delta, "Hello");
assert!(chunk.model.is_none());
assert_eq!(chunk.tokens_in, Some(10));
assert_eq!(chunk.tokens_out, Some(3));
}
#[test]
fn parse_sse_anthropic_with_stop_reason() {
let line = r#"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"","stop_reason":"end_turn"}}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::AnthropicMessages).unwrap();
assert_eq!(chunk.delta, "");
assert_eq!(chunk.finish_reason.as_deref(), Some("end_turn"));
}
#[test]
fn parse_sse_anthropic_no_text_returns_empty() {
let line = r#"data: {"type":"content_block_delta","delta":{"type":"text_delta"}}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::AnthropicMessages).unwrap();
assert_eq!(chunk.delta, "");
}
#[test]
fn parse_sse_anthropic_missing_delta_returns_none() {
let line = r#"data: {"type":"ping"}"#;
let result = parse_sse_chunk(line, &ApiFormat::AnthropicMessages);
assert!(result.is_none());
}
#[test]
fn parse_sse_google_chunk() {
let line = r#"data: {"candidates":[{"content":{"parts":[{"text":"World"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2}}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::GoogleGenerativeAi).unwrap();
assert_eq!(chunk.delta, "World");
assert_eq!(chunk.finish_reason.as_deref(), Some("STOP"));
assert_eq!(chunk.tokens_in, Some(5));
assert_eq!(chunk.tokens_out, Some(2));
assert!(chunk.model.is_none());
}
#[test]
fn parse_sse_google_no_text_returns_empty() {
let line = r#"data: {"candidates":[{"content":{"parts":[{}],"role":"model"}}]}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::GoogleGenerativeAi).unwrap();
assert_eq!(chunk.delta, "");
}
#[test]
fn parse_sse_google_missing_candidates_returns_none() {
let line = r#"data: {"error":"something"}"#;
assert!(parse_sse_chunk(line, &ApiFormat::GoogleGenerativeAi).is_none());
}
#[test]
fn parse_sse_invalid_json_returns_none() {
let line = "data: {not-json}";
assert!(parse_sse_chunk(line, &ApiFormat::OpenAiCompletions).is_none());
}
#[test]
fn parse_sse_done_for_all_formats() {
assert!(parse_sse_chunk("data: [DONE]", &ApiFormat::AnthropicMessages).is_none());
assert!(parse_sse_chunk("data: [DONE]", &ApiFormat::GoogleGenerativeAi).is_none());
assert!(parse_sse_chunk("data: [DONE]", &ApiFormat::OpenAiResponses).is_none());
}
#[test]
fn parse_sse_openai_with_finish_reason() {
let line = r#"data: {"id":"x","choices":[{"index":0,"delta":{"content":"bye"},"finish_reason":"stop"}],"model":"gpt-4o"}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::OpenAiCompletions).unwrap();
assert_eq!(chunk.delta, "bye");
assert_eq!(chunk.finish_reason.as_deref(), Some("stop"));
}
#[test]
fn parse_sse_openai_missing_content_returns_empty_delta() {
let line = r#"data: {"id":"x","choices":[{"index":0,"delta":{},"finish_reason":null}]}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::OpenAiCompletions).unwrap();
assert_eq!(chunk.delta, "");
}
#[test]
fn parse_sse_openai_null_content_returns_empty_delta() {
let line = r#"data: {"id":"x","choices":[{"index":0,"delta":{"content":null},"finish_reason":null}]}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::OpenAiCompletions).unwrap();
assert_eq!(chunk.delta, "");
}
#[test]
fn parse_sse_openai_responses_format() {
let line = r#"data: {"id":"x","choices":[{"index":0,"delta":{"content":"test"},"finish_reason":null}],"model":"gpt-4o"}"#;
let chunk = parse_sse_chunk(line, &ApiFormat::OpenAiResponses).unwrap();
assert_eq!(chunk.delta, "test");
}
#[test]
fn translate_anthropic_multimodal_message() {
let req = UnifiedRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![UnifiedMessage::multimodal(
"user",
vec![
ContentPart::text("What is this?"),
ContentPart::image_url("https://example.com/img.png"),
ContentPart::image_base64("image/jpeg", "abc123"),
],
)],
max_tokens: Some(512),
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::AnthropicMessages).unwrap();
let msgs = body["messages"].as_array().unwrap();
let content = msgs[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 3);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "url");
assert_eq!(content[2]["type"], "image");
assert_eq!(content[2]["source"]["type"], "base64");
}
#[test]
fn translate_anthropic_system_from_message_when_no_system_field() {
let req = UnifiedRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![
UnifiedMessage::text("system", "Be concise."),
UnifiedMessage::text("user", "Hello"),
],
max_tokens: None,
temperature: None,
system: None, quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::AnthropicMessages).unwrap();
assert_eq!(body["system"], "Be concise.");
let msgs = body["messages"].as_array().unwrap();
assert!(msgs.iter().all(|m| m["role"] != "system"));
}
#[test]
fn translate_anthropic_no_max_tokens() {
let req = UnifiedRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![UnifiedMessage::text("user", "Hi")],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::AnthropicMessages).unwrap();
assert!(body.get("max_tokens").is_none() || body["max_tokens"].is_null());
}
#[test]
fn translate_openai_multimodal_message() {
let req = UnifiedRequest {
model: "gpt-4o".into(),
messages: vec![UnifiedMessage::multimodal(
"user",
vec![
ContentPart::text("Describe this"),
ContentPart::image_url("https://example.com/photo.jpg"),
],
)],
max_tokens: Some(1000),
temperature: Some(0.5),
system: Some("You are an image analyzer".into()),
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::OpenAiCompletions).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs[0]["role"], "system");
let content = msgs[1]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image_url");
}
#[test]
fn translate_openai_no_system_no_max_tokens_no_temp() {
let req = UnifiedRequest {
model: "gpt-4o-mini".into(),
messages: vec![UnifiedMessage::text("user", "Hi")],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::OpenAiCompletions).unwrap();
assert!(body.get("max_tokens").is_none() || body["max_tokens"].is_null());
assert!(body.get("temperature").is_none() || body["temperature"].is_null());
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 1); }
#[test]
fn translate_openai_skips_empty_assistant_messages() {
let req = UnifiedRequest {
model: "gpt-4o-mini".into(),
messages: vec![
UnifiedMessage::text("user", "hello"),
UnifiedMessage::text("assistant", " "),
UnifiedMessage::text("assistant", "real answer"),
],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::OpenAiCompletions).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[1]["content"], "real answer");
}
#[test]
fn translate_openai_responses_no_max_tokens() {
let req = UnifiedRequest {
model: "gpt-4o".into(),
messages: vec![UnifiedMessage::text("user", "Hello")],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::OpenAiResponses).unwrap();
assert!(body.get("max_output_tokens").is_none() || body["max_output_tokens"].is_null());
}
#[test]
fn translate_openai_responses_skips_empty_assistant_messages() {
let req = UnifiedRequest {
model: "gpt-4o".into(),
messages: vec![
UnifiedMessage::text("user", "hello"),
UnifiedMessage::text("assistant", ""),
UnifiedMessage::text("assistant", "ready"),
],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::OpenAiResponses).unwrap();
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], "user");
assert_eq!(input[1]["role"], "assistant");
assert_eq!(input[1]["content"][0]["text"], "ready");
}
#[test]
fn translate_google_no_gen_config_fields() {
let req = UnifiedRequest {
model: "gemini-2.5-flash".into(),
messages: vec![UnifiedMessage::text("user", "Hello")],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::GoogleGenerativeAi).unwrap();
let gen_cfg = body["generationConfig"].as_object().unwrap();
assert!(gen_cfg.is_empty(), "no gen config fields if none set");
}
#[test]
fn translate_google_filters_system_messages() {
let req = UnifiedRequest {
model: "gemini-2.5-flash".into(),
messages: vec![
UnifiedMessage::text("system", "Be helpful"),
UnifiedMessage::text("user", "Hello"),
],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::GoogleGenerativeAi).unwrap();
let contents = body["contents"].as_array().unwrap();
assert_eq!(contents.len(), 1);
assert_eq!(contents[0]["role"], "user");
assert_eq!(body["systemInstruction"]["parts"][0]["text"], "Be helpful");
}
#[test]
fn translate_google_skips_empty_assistant_messages() {
let req = UnifiedRequest {
model: "gemini-2.5-flash".into(),
messages: vec![
UnifiedMessage::text("user", "hello"),
UnifiedMessage::text("assistant", " "),
UnifiedMessage::text("assistant", "ok"),
],
max_tokens: None,
temperature: None,
system: None,
quality_target: None,
tools: vec![],
};
let body = translate_request(&req, ApiFormat::GoogleGenerativeAi).unwrap();
let contents = body["contents"].as_array().unwrap();
assert_eq!(contents.len(), 2);
assert_eq!(contents[0]["role"], "user");
assert_eq!(contents[1]["role"], "model");
assert_eq!(contents[1]["parts"][0]["text"], "ok");
}
#[test]
fn translate_openai_responses_includes_tools() {
let req = UnifiedRequest {
model: "gpt-4o".into(),
messages: vec![UnifiedMessage::text("user", "Find docs")],
max_tokens: Some(300),
temperature: Some(0.2),
system: Some("Use tools when needed.".into()),
quality_target: None,
tools: vec![ToolDefinition {
name: "web-search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}),
}],
};
let body = translate_request(&req, ApiFormat::OpenAiResponses).unwrap();
assert_eq!(body["tools"][0]["type"], "function");
assert_eq!(body["tools"][0]["name"], "web-search");
}
#[test]
fn translate_google_includes_tools() {
let req = UnifiedRequest {
model: "gemini-2.5-flash".into(),
messages: vec![UnifiedMessage::text("user", "Find docs")],
max_tokens: None,
temperature: None,
system: Some("Prefer tool usage.".into()),
quality_target: None,
tools: vec![ToolDefinition {
name: "web-search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}),
}],
};
let body = translate_request(&req, ApiFormat::GoogleGenerativeAi).unwrap();
assert_eq!(
body["tools"][0]["functionDeclarations"][0]["name"],
"web-search"
);
assert_eq!(
body["systemInstruction"]["parts"][0]["text"],
"Prefer tool usage."
);
}
#[test]
fn parse_anthropic_response_empty_content() {
let body = serde_json::json!({
"content": [],
"model": "claude-sonnet-4-20250514",
"usage": {"input_tokens": 0, "output_tokens": 0}
});
let resp = translate_response(&body, ApiFormat::AnthropicMessages).unwrap();
assert_eq!(resp.content, "");
}
#[test]
fn parse_openai_response_no_choices_errors() {
let body = serde_json::json!({"choices": []});
let err = translate_response(&body, ApiFormat::OpenAiCompletions);
assert!(err.is_err());
}
#[test]
fn parse_openai_responses_empty_output() {
let body = serde_json::json!({
"output": [],
"model": "gpt-4o",
"usage": {"input_tokens": 0, "output_tokens": 0}
});
let resp = translate_response(&body, ApiFormat::OpenAiResponses).unwrap();
assert_eq!(resp.content, "");
}
#[test]
fn parse_google_response_no_candidates() {
let body = serde_json::json!({
"candidates": [],
"modelVersion": "gemini-2.5-flash"
});
let resp = translate_response(&body, ApiFormat::GoogleGenerativeAi).unwrap();
assert_eq!(resp.content, "");
}
#[test]
fn parse_anthropic_response_missing_usage() {
let body = serde_json::json!({
"content": [{"type": "text", "text": "Hello"}],
"model": "claude-sonnet-4-20250514"
});
let resp = translate_response(&body, ApiFormat::AnthropicMessages).unwrap();
assert_eq!(resp.tokens_in, 0);
assert_eq!(resp.tokens_out, 0);
}
#[test]
fn parts_to_openai_base64_image() {
let parts = vec![ContentPart::image_base64("image/png", "base64data")];
let result = parts_to_openai(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr[0]["type"], "image_url");
let url = arr[0]["image_url"]["url"].as_str().unwrap();
assert!(url.starts_with("data:image/png;base64,"));
assert!(url.contains("base64data"));
}
#[test]
fn parts_to_openai_audio_becomes_text() {
let parts = vec![ContentPart::audio_transcription("Hello world", "whisper")];
let result = parts_to_openai(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[0]["text"], "Hello world");
}
#[test]
fn parts_to_anthropic_url_image() {
let parts = vec![ContentPart::image_url("https://example.com/photo.jpg")];
let result = parts_to_anthropic(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr[0]["type"], "image");
assert_eq!(arr[0]["source"]["type"], "url");
assert_eq!(arr[0]["source"]["url"], "https://example.com/photo.jpg");
}
#[test]
fn parts_to_anthropic_audio_becomes_text() {
let parts = vec![ContentPart::audio_transcription("Transcript", "microphone")];
let result = parts_to_anthropic(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[0]["text"], "Transcript");
}
#[test]
fn content_part_image_base64_to_text() {
let part = ContentPart::image_base64("image/webp", "data123");
assert_eq!(part.to_text(), "[Image: image/webp]");
}
#[test]
fn is_multimodal_false_with_text_only_parts() {
let msg = UnifiedMessage {
role: "user".into(),
content: "hello".into(),
parts: Some(vec![ContentPart::text("hello")]),
};
assert!(!msg.is_multimodal());
}
#[test]
fn unified_request_serialization_roundtrip() {
let req = UnifiedRequest {
model: "gpt-4o".into(),
messages: vec![UnifiedMessage::text("user", "hello")],
max_tokens: Some(100),
temperature: Some(0.5),
system: Some("sys".into()),
quality_target: Some(0.9),
tools: vec![],
};
let json = serde_json::to_string(&req).unwrap();
let parsed: UnifiedRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.model, "gpt-4o");
assert_eq!(parsed.quality_target, Some(0.9));
assert!(parsed.tools.is_empty());
}
#[test]
fn unified_response_serialization_roundtrip() {
let resp = UnifiedResponse {
content: "Hello".into(),
model: "gpt-4o".into(),
tokens_in: 10,
tokens_out: 5,
finish_reason: Some("stop".into()),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: UnifiedResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.content, "Hello");
assert_eq!(parsed.finish_reason.as_deref(), Some("stop"));
}
#[test]
fn stream_chunk_serialization_roundtrip() {
let chunk = StreamChunk {
delta: "hi".into(),
model: Some("gpt-4o".into()),
finish_reason: None,
tokens_in: Some(5),
tokens_out: None,
};
let json = serde_json::to_string(&chunk).unwrap();
let parsed: StreamChunk = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.delta, "hi");
assert_eq!(parsed.tokens_in, Some(5));
assert!(parsed.tokens_out.is_none());
}
#[test]
fn parts_to_openai_mixed_all_types() {
let parts = vec![
ContentPart::text("Look:"),
ContentPart::image_url("https://a.com/img.png"),
ContentPart::image_base64("image/gif", "R0lGOD"),
ContentPart::audio_transcription("speech", "mic"),
];
let result = parts_to_openai(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 4);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[1]["type"], "image_url");
assert_eq!(arr[2]["type"], "image_url");
assert_eq!(arr[3]["type"], "text");
}
#[test]
fn parts_to_anthropic_mixed_all_types() {
let parts = vec![
ContentPart::text("Look:"),
ContentPart::image_url("https://a.com/img.png"),
ContentPart::image_base64("image/gif", "R0lGOD"),
ContentPart::audio_transcription("speech", "mic"),
];
let result = parts_to_anthropic(&parts);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 4);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[1]["type"], "image");
assert_eq!(arr[2]["type"], "image");
assert_eq!(arr[3]["type"], "text");
}
#[test]
fn image_url_detail_passed_through_openai() {
let part = ContentPart::ImageUrl {
url: "https://a.com/img.png".into(),
detail: Some("high".into()),
};
let result = parts_to_openai(&[part]);
let arr = result.as_array().unwrap();
assert_eq!(arr[0]["image_url"]["detail"], "high");
}
#[test]
fn parse_openai_responses_with_function_call() {
let body = serde_json::json!({
"output": [
{
"type": "message",
"content": [{"type": "output_text", "text": "Let me check that."}]
},
{
"type": "function_call",
"name": "web-search",
"arguments": r#"{"query":"rust async"}"#
}
],
"model": "gpt-4o",
"usage": {"input_tokens": 20, "output_tokens": 15}
});
let resp = translate_response(&body, ApiFormat::OpenAiResponses).unwrap();
assert!(resp.content.contains("Let me check that."));
assert!(resp.content.contains(r#""tool_call""#));
assert!(resp.content.contains(r#""web-search""#));
assert!(resp.content.contains(r#""rust async""#));
assert_eq!(resp.tokens_in, 20);
assert_eq!(resp.tokens_out, 15);
}
#[test]
fn parse_openai_responses_function_call_only() {
let body = serde_json::json!({
"output": [{
"type": "function_call",
"name": "memory-recall",
"arguments": r#"{"topic":"project goals"}"#
}],
"model": "gpt-4o",
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let resp = translate_response(&body, ApiFormat::OpenAiResponses).unwrap();
assert!(resp.content.starts_with(r#"{"tool_call""#));
assert!(resp.content.contains("memory-recall"));
}
#[test]
fn parse_openai_responses_multiple_function_calls() {
let body = serde_json::json!({
"output": [
{
"type": "function_call",
"name": "tool-a",
"arguments": r#"{"x":1}"#
},
{
"type": "function_call",
"name": "tool-b",
"arguments": r#"{"y":2}"#
}
],
"model": "gpt-4o",
"usage": {"input_tokens": 5, "output_tokens": 3}
});
let resp = translate_response(&body, ApiFormat::OpenAiResponses).unwrap();
assert!(resp.content.contains("tool-a"));
assert!(resp.content.contains("tool-b"));
let tc_count = resp.content.matches(r#""tool_call""#).count();
assert_eq!(tc_count, 2);
}
#[test]
fn parse_google_response_with_function_call() {
let body = serde_json::json!({
"candidates": [{
"content": {
"parts": [
{"text": "I'll look that up."},
{"functionCall": {"name": "web-search", "args": {"query": "rust async"}}}
],
"role": "model"
},
"finishReason": "STOP"
}],
"modelVersion": "gemini-2.5-flash",
"usageMetadata": {"promptTokenCount": 12, "candidatesTokenCount": 8}
});
let resp = translate_response(&body, ApiFormat::GoogleGenerativeAi).unwrap();
assert!(resp.content.contains("I'll look that up."));
assert!(resp.content.contains(r#""tool_call""#));
assert!(resp.content.contains("web-search"));
assert!(resp.content.contains("rust async"));
assert_eq!(resp.tokens_in, 12);
assert_eq!(resp.tokens_out, 8);
}
#[test]
fn parse_google_response_function_call_only() {
let body = serde_json::json!({
"candidates": [{
"content": {
"parts": [
{"functionCall": {"name": "memory-recall", "args": {"topic": "goals"}}}
],
"role": "model"
},
"finishReason": "STOP"
}],
"modelVersion": "gemini-2.5-flash",
"usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 3}
});
let resp = translate_response(&body, ApiFormat::GoogleGenerativeAi).unwrap();
assert!(resp.content.starts_with(r#"{"tool_call""#));
assert!(resp.content.contains("memory-recall"));
}
#[test]
fn parse_google_response_multiple_function_calls() {
let body = serde_json::json!({
"candidates": [{
"content": {
"parts": [
{"functionCall": {"name": "tool-a", "args": {"x": 1}}},
{"functionCall": {"name": "tool-b", "args": {"y": 2}}}
],
"role": "model"
},
"finishReason": "STOP"
}],
"modelVersion": "gemini-2.5-flash",
"usageMetadata": {"promptTokenCount": 4, "candidatesTokenCount": 2}
});
let resp = translate_response(&body, ApiFormat::GoogleGenerativeAi).unwrap();
assert!(resp.content.contains("tool-a"));
assert!(resp.content.contains("tool-b"));
let tc_count = resp.content.matches(r#""tool_call""#).count();
assert_eq!(tc_count, 2);
}
}