use serde_json::Value;
use std::collections::HashMap;
pub fn convert_schema_types(schema: Value) -> Value {
let Some(obj) = schema.as_object() else {
return schema;
};
let mut cleaned = serde_json::Map::new();
// 1. Map type and convert to uppercase if string
if let Some(t_val) = obj.get("type") {
if let Some(t_str) = t_val.as_str() {
cleaned.insert("type".to_string(), serde_json::json!(t_str.to_uppercase()));
} else {
cleaned.insert("type".to_string(), t_val.clone());
}
}
// 2. Keep format
if let Some(format_val) = obj.get("format") {
cleaned.insert("format".to_string(), format_val.clone());
}
// 3. Keep description
if let Some(desc_val) = obj.get("description") {
cleaned.insert("description".to_string(), desc_val.clone());
}
// 4. Keep nullable
if let Some(null_val) = obj.get("nullable") {
cleaned.insert("nullable".to_string(), null_val.clone());
}
// 5. Keep enum
if let Some(enum_val) = obj.get("enum") {
cleaned.insert("enum".to_string(), enum_val.clone());
}
// 6. Keep required
if let Some(req_val) = obj.get("required") {
cleaned.insert("required".to_string(), req_val.clone());
}
// 7. Recurse properties
if let Some(props_val) = obj.get("properties") {
if let Some(props_obj) = props_val.as_object() {
let mut cleaned_props = serde_json::Map::new();
for (k, v) in props_obj {
cleaned_props.insert(k.clone(), convert_schema_types(v.clone()));
}
cleaned.insert("properties".to_string(), Value::Object(cleaned_props));
}
}
// 8. Recurse items
if let Some(items_val) = obj.get("items") {
cleaned.insert("items".to_string(), convert_schema_types(items_val.clone()));
}
Value::Object(cleaned)
}
fn make_function_declaration(name: &str, description: &str, schema: Option<&Value>) -> Value {
let mut decl = serde_json::json!({
"name": name,
"description": description,
});
if let Some(params) = schema {
let mapped_params = convert_schema_types(params.clone());
decl.as_object_mut()
.unwrap()
.insert("parameters".to_string(), mapped_params);
}
decl
}
fn wrap_function_declarations(declarations: Vec<Value>) -> Option<Value> {
if !declarations.is_empty() {
Some(serde_json::json!([{
"functionDeclarations": declarations
}]))
} else {
None
}
}
pub fn map_openai_tools_to_gemini(openai_tools: &Value) -> Option<Value> {
let tools_arr = openai_tools.as_array()?;
let mut declarations = Vec::new();
for tool in tools_arr {
if tool["type"].as_str() == Some("function") {
let fn_obj = &tool["function"];
declarations.push(make_function_declaration(
fn_obj["name"].as_str().unwrap_or(""),
fn_obj["description"].as_str().unwrap_or(""),
fn_obj.get("parameters"),
));
}
}
wrap_function_declarations(declarations)
}
pub fn map_model_name(model: &str, thinking_level: Option<&str>) -> String {
let model_lower = model.to_lowercase();
let exact_mapping = [
("gemini-3.5-flash-medium", "gemini-3.5-flash-low"),
("gemini-3.5-flash-high", "gemini-3-flash-agent"),
("gemini-3.5-flash-low", "gemini-3.5-flash-extra-low"),
("gemini-3.1-pro-low", "gemini-3.1-pro-low"),
("gemini-3.1-pro-high", "gemini-pro-agent"),
("claude-sonnet-4-6", "gemini-3.5-flash-low"),
("claude-opus-4-6-thinking", "gemini-3-flash-agent"),
("gpt-oss-120b-medium", "gpt-oss-120b-medium"),
("gemini 3.5 flash (medium)", "gemini-3.5-flash-low"),
("gemini 3.5 flash (high)", "gemini-3-flash-agent"),
("gemini 3.5 flash (low)", "gemini-3.5-flash-extra-low"),
("gemini 3.1 pro (low)", "gemini-3.1-pro-low"),
("gemini 3.1 pro (high)", "gemini-pro-agent"),
("claude sonnet 4.6 (thinking)", "gemini-3.5-flash-low"),
("claude opus 4.6 (thinking)", "gemini-3-flash-agent"),
("gpt-oss 120b (medium)", "gpt-oss-120b-medium"),
];
for (from, to) in exact_mapping {
if model_lower == from {
return to.to_string();
}
}
let valid_models = [
"gemini-3-flash",
"gemini-2.5-flash",
"gemini-2.5-flash-thinking",
"gemini-2.5-flash-lite",
"gemini-3.1-flash-lite",
"gemini-2.5-pro",
"gemini-3.5-flash-low",
"gemini-3-flash-agent",
"gemini-3.5-flash-extra-low",
"gemini-pro-agent",
"gemini-3.1-pro-low",
"gemini-3.1-pro-high",
"claude-opus-4-6-thinking",
"claude-sonnet-4-6",
"gpt-oss-120b-medium",
];
if valid_models.contains(&model_lower.as_str()) {
return model_lower;
}
if model_lower.contains("flash") {
if let Some(lvl) = thinking_level {
match lvl.to_lowercase().as_str() {
"high" => "gemini-3-flash-agent".to_string(),
"low" => "gemini-3.5-flash-extra-low".to_string(),
_ => "gemini-3.5-flash-low".to_string(),
}
} else if model_lower.contains("high") {
"gemini-3-flash-agent".to_string()
} else if model_lower.contains("low") {
"gemini-3.5-flash-extra-low".to_string()
} else {
"gemini-3.5-flash-low".to_string()
}
} else if model_lower.contains("pro") {
if model_lower.contains("high") {
"gemini-pro-agent".to_string()
} else {
"gemini-3.1-pro-low".to_string()
}
} else if model_lower.contains("sonnet") {
"gemini-3.5-flash-low".to_string()
} else if model_lower.contains("haiku") {
"gemini-3.5-flash-extra-low".to_string()
} else if model_lower.contains("opus") {
"gemini-3-flash-agent".to_string()
} else if model_lower.contains("gpt-oss") {
"gpt-oss-120b-medium".to_string()
} else {
model.to_string()
}
}
pub fn map_openai_messages_to_gemini(
messages_val: &Value,
signature_cache: &HashMap<String, String>,
) -> (Vec<Value>, Option<Value>) {
let mut contents = Vec::new();
let mut system_instruction = None;
let mut tool_name_by_id = HashMap::new();
if let Some(arr) = messages_val.as_array() {
for msg in arr {
if let Some(tc_arr) = msg["tool_calls"].as_array() {
for tc in tc_arr {
if let (Some(id), Some(name)) =
(tc["id"].as_str(), tc["function"]["name"].as_str())
{
tool_name_by_id.insert(id.to_string(), name.to_string());
}
}
}
}
}
if let Some(arr) = messages_val.as_array() {
for msg in arr {
let role = msg["role"].as_str().unwrap_or("user");
let content = msg["content"].as_str().unwrap_or("");
match role {
"system" => {
system_instruction = Some(serde_json::json!({
"role": "user",
"parts": [{"text": content}]
}));
}
"tool" => {
let tc_id = msg["tool_call_id"].as_str().unwrap_or("");
let fn_name = tool_name_by_id
.get(tc_id)
.cloned()
.unwrap_or_else(|| "unknown_tool".to_string());
let response_obj = match serde_json::from_str::<Value>(content) {
Ok(val) => {
if val.is_object() {
val
} else {
serde_json::json!({ "result": val })
}
}
Err(_) => {
serde_json::json!({ "result": content })
}
};
let mut parts = vec![serde_json::json!({
"functionResponse": {
"name": fn_name,
"response": response_obj,
"id": tc_id
}
})];
// Find and append any inlineData parts from the tool response string ONLY if it is read_media tool
if fn_name == "read_media" {
let re = get_data_url_re();
for mat in re.captures_iter(content) {
let mime = mat.get(1).unwrap().as_str();
let data = mat.get(2).unwrap().as_str();
if let Some(part) = strict_inline_data_part(mime, data) {
parts.push(part);
}
}
}
contents.push(serde_json::json!({
"role": "model",
"parts": parts
}));
}
"assistant" | "model" => {
let mut parts = Vec::new();
if !content.is_empty() {
parts.push(serde_json::json!({ "text": content }));
}
if let Some(tc_arr) = msg["tool_calls"].as_array() {
for tc in tc_arr {
let func = &tc["function"];
let func_name = func["name"].as_str().unwrap_or("");
let args_val = if func["arguments"].is_string() {
let args_str = func["arguments"].as_str().unwrap_or("{}");
serde_json::from_str::<Value>(args_str)
.unwrap_or_else(|_| serde_json::json!({}))
} else if func["arguments"].is_object() {
func["arguments"].clone()
} else {
serde_json::json!({})
};
let tc_id = tc["id"].as_str().unwrap_or("");
let signature =
signature_cache.get(tc_id).map(|s| s.as_str()).unwrap_or("");
parts.push(serde_json::json!({
"functionCall": {
"name": func_name,
"args": args_val,
"id": tc_id
},
"thoughtSignature": signature
}));
}
}
if !parts.is_empty() {
contents.push(serde_json::json!({
"role": "model",
"parts": parts
}));
}
}
_ => {
let mut parts = Vec::new();
if msg["content"].is_string() {
if !content.is_empty() {
parse_text_with_media(content, &mut parts);
}
} else if let Some(content_arr) = msg["content"].as_array() {
for item in content_arr {
if let Some(item_type) = item["type"].as_str() {
match item_type {
"text" => {
if let Some(text) = item["text"].as_str() {
if !text.is_empty() {
parts.push(serde_json::json!({ "text": text }));
}
}
}
"image_url" => {
if let Some(url) = item["image_url"]["url"].as_str() {
if let Some((mime, data)) = parse_data_url(url) {
parts.push(lenient_inline_data_part(&mime, &data));
}
}
}
"image" | "document" | "audio" | "video" | "file" => {
// Support Anthropic-style base64 source
if let Some(source) = item["source"].as_object() {
if source.get("type").and_then(|t| t.as_str())
== Some("base64")
{
if let (Some(media_type), Some(data)) = (
source
.get("media_type")
.and_then(|t| t.as_str()),
source.get("data").and_then(|d| d.as_str()),
) {
parts.push(lenient_inline_data_part(
media_type, data,
));
}
}
}
// Support OpenAI-style audio_url/video_url/file_url if passed
let url_key = format!("{}_url", item_type);
if let Some(url) = item[&url_key]["url"].as_str() {
if let Some((mime, data)) = parse_data_url(url) {
parts.push(lenient_inline_data_part(&mime, &data));
}
}
}
_ => {}
}
}
}
}
if !parts.is_empty() {
contents.push(serde_json::json!({
"role": "user",
"parts": parts
}));
}
}
}
}
}
(contents, system_instruction)
}
use std::sync::OnceLock;
static DATA_URL_RE: OnceLock<regex::Regex> = OnceLock::new();
fn get_data_url_re() -> &'static regex::Regex {
DATA_URL_RE.get_or_init(|| {
regex::Regex::new(r"data:([a-zA-Z0-9\-+\.]+/[a-zA-Z0-9\-+\.]+);base64,([a-zA-Z0-9/+=]+)")
.unwrap()
})
}
pub fn sniff_mime_type(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
Some("image/png")
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if bytes.starts_with(b"GIF8") {
Some("image/gif")
} else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
Some("image/webp")
} else if bytes.starts_with(b"%PDF") {
Some("application/pdf")
} else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WAVE" {
Some("audio/wav")
} else if bytes.starts_with(b"ID3")
|| (bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0)
{
Some("audio/mpeg")
} else if bytes.len() >= 8 && &bytes[4..8] == b"ftyp" {
Some("video/mp4")
} else {
None
}
}
pub fn sniff_or_validate_mime(mime: &str, bytes: &[u8]) -> Option<String> {
let mime_lower = mime.to_lowercase();
if mime_lower == "application/octet-stream" || mime_lower.is_empty() {
if let Some(sniffed) = sniff_mime_type(bytes) {
return Some(sniffed.to_string());
}
}
if validate_media_signature(mime, bytes) {
Some(mime.to_string())
} else {
sniff_mime_type(bytes).map(str::to_owned)
}
}
fn validate_media_signature(mime: &str, bytes: &[u8]) -> bool {
let mime_lower = mime.to_lowercase();
if mime_lower.starts_with("image/png") {
bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
} else if mime_lower.starts_with("image/jpeg") || mime_lower.starts_with("image/jpg") {
bytes.starts_with(&[0xFF, 0xD8, 0xFF])
} else if mime_lower.starts_with("image/gif") {
bytes.starts_with(b"GIF8")
} else if mime_lower.starts_with("image/webp") {
bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP"
} else if mime_lower.starts_with("application/pdf") {
bytes.starts_with(b"%PDF")
} else if mime_lower.starts_with("audio/wav") || mime_lower.starts_with("audio/x-wav") {
bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WAVE"
} else if mime_lower.starts_with("audio/mpeg") || mime_lower.starts_with("audio/mp3") {
bytes.starts_with(b"ID3")
|| (bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0)
} else if mime_lower.starts_with("video/mp4") {
bytes.len() >= 8 && &bytes[4..8] == b"ftyp"
} else {
!bytes.is_empty()
}
}
fn strict_inline_data_part(mime: &str, raw_data: &str) -> Option<Value> {
let cleaned_data: String = raw_data.chars().filter(|c| !c.is_whitespace()).collect();
use base64::{engine::general_purpose, Engine as _};
let decoded = general_purpose::STANDARD
.decode(cleaned_data.as_bytes())
.ok()?;
let final_mime = sniff_or_validate_mime(mime, &decoded)?;
Some(serde_json::json!({
"inlineData": {
"mimeType": final_mime,
"data": cleaned_data
}
}))
}
fn lenient_inline_data_part(mime: &str, raw_data: &str) -> Value {
let cleaned_data: String = raw_data.chars().filter(|c| !c.is_whitespace()).collect();
use base64::{engine::general_purpose, Engine as _};
let final_mime = if let Ok(decoded) = general_purpose::STANDARD.decode(cleaned_data.as_bytes())
{
sniff_or_validate_mime(mime, &decoded).unwrap_or_else(|| mime.to_string())
} else {
mime.to_string()
};
serde_json::json!({
"inlineData": {
"mimeType": final_mime,
"data": cleaned_data
}
})
}
fn parse_text_with_media(text: &str, parts: &mut Vec<Value>) {
let re = get_data_url_re();
let mut last_match_end = 0;
for mat in re.captures_iter(text) {
let full_match = mat.get(0).unwrap();
let mime = mat.get(1).unwrap().as_str();
let data = mat.get(2).unwrap().as_str();
// Push preceding text if any
let preceding = &text[last_match_end..full_match.start()];
if !preceding.is_empty() {
parts.push(serde_json::json!({ "text": preceding }));
}
// Push inlineData part ONLY if it decodes successfully and matches signature
if let Some(part) = strict_inline_data_part(mime, data) {
parts.push(part);
} else {
// Push as raw text if decoding failed to preserve context and avoid API crashes
parts.push(serde_json::json!({ "text": full_match.as_str() }));
}
last_match_end = full_match.end();
}
// Push remaining text
let remaining = &text[last_match_end..];
if !remaining.is_empty() {
parts.push(serde_json::json!({ "text": remaining }));
}
}
fn parse_data_url(url: &str) -> Option<(String, String)> {
if !url.starts_with("data:") {
return None;
}
let comma_idx = url.find(',')?;
let header = &url[..comma_idx];
let data = &url[comma_idx + 1..];
if !header.ends_with(";base64") {
return None;
}
let mime = &header["data:".len()..header.len() - ";base64".len()];
Some((mime.to_string(), data.to_string()))
}
pub fn map_anthropic_messages_to_gemini(
messages_val: &Value,
signature_cache: &HashMap<String, String>,
) -> (Vec<Value>, Option<Value>) {
let mut contents = Vec::new();
let mut tool_name_by_id = HashMap::new();
// First scan all messages to extract tool names from tool_use blocks
if let Some(arr) = messages_val.as_array() {
for msg in arr {
let content_val = &msg["content"];
if let Some(content_arr) = content_val.as_array() {
for block in content_arr {
if block["type"].as_str() == Some("tool_use") {
if let (Some(id), Some(name)) =
(block["id"].as_str(), block["name"].as_str())
{
tool_name_by_id.insert(id.to_string(), name.to_string());
}
}
}
}
}
}
if let Some(arr) = messages_val.as_array() {
for msg in arr {
let role = msg["role"].as_str().unwrap_or("user");
let gemini_role = match role {
"assistant" => "model",
_ => "user",
};
let mut parts = Vec::new();
let content_val = &msg["content"];
if content_val.is_string() {
let content_str = content_val.as_str().unwrap_or("");
if !content_str.is_empty() {
parse_text_with_media(content_str, &mut parts);
}
} else if let Some(content_arr) = content_val.as_array() {
for block in content_arr {
let block_type = block["type"].as_str().unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block["text"].as_str() {
if !text.is_empty() {
parts.push(serde_json::json!({ "text": text }));
}
}
}
"image" | "document" => {
if let Some(source) = block["source"].as_object() {
if source.get("type").and_then(|t| t.as_str()) == Some("base64") {
if let (Some(media_type), Some(data)) = (
source.get("media_type").and_then(|t| t.as_str()),
source.get("data").and_then(|d| d.as_str()),
) {
parts.push(lenient_inline_data_part(media_type, data));
}
}
}
}
"tool_use" => {
let func_name = block["name"].as_str().unwrap_or("");
let args_val = block["input"].clone();
let tc_id = block["id"].as_str().unwrap_or("");
let signature =
signature_cache.get(tc_id).map(|s| s.as_str()).unwrap_or("");
parts.push(serde_json::json!({
"functionCall": {
"name": func_name,
"args": args_val,
"id": tc_id
},
"thoughtSignature": signature
}));
}
"tool_result" => {
let tc_id = block["tool_use_id"].as_str().unwrap_or("");
let fn_name = tool_name_by_id
.get(tc_id)
.cloned()
.unwrap_or_else(|| "unknown_tool".to_string());
let block_content = &block["content"];
let (response_obj, nested_parts) = if block_content.is_string() {
let content_str = block_content.as_str().unwrap_or("");
let response_val = match serde_json::from_str::<Value>(content_str)
{
Ok(val) => {
if val.is_object() {
val
} else {
serde_json::json!({ "result": val })
}
}
Err(_) => {
serde_json::json!({ "result": content_str })
}
};
(response_val, vec![])
} else if block_content.is_object() {
(block_content.clone(), vec![])
} else if let Some(nested_arr) = block_content.as_array() {
let mut text_accum = String::new();
let mut media_parts = Vec::new();
for nested_block in nested_arr {
if nested_block["type"].as_str() == Some("text") {
if let Some(txt) = nested_block["text"].as_str() {
text_accum.push_str(txt);
}
} else if nested_block["type"].as_str() == Some("image")
|| nested_block["type"].as_str() == Some("document")
{
if let Some(source) = nested_block["source"].as_object() {
if source.get("type").and_then(|t| t.as_str())
== Some("base64")
{
if let (Some(media_type), Some(data)) = (
source
.get("media_type")
.and_then(|t| t.as_str()),
source.get("data").and_then(|d| d.as_str()),
) {
media_parts.push(lenient_inline_data_part(
media_type, data,
));
}
}
}
}
}
let response_val = serde_json::json!({ "result": text_accum });
(response_val, media_parts)
} else {
(
serde_json::json!({ "result": block_content.to_string() }),
vec![],
)
};
if !parts.is_empty() {
contents.push(serde_json::json!({
"role": "user",
"parts": parts
}));
parts = Vec::new();
}
let mut tool_parts = vec![serde_json::json!({
"functionResponse": {
"name": fn_name,
"response": response_obj,
"id": tc_id
}
})];
for p in nested_parts {
tool_parts.push(p);
}
contents.push(serde_json::json!({
"role": "user",
"parts": tool_parts
}));
}
_ => {}
}
}
}
if !parts.is_empty() {
contents.push(serde_json::json!({
"role": gemini_role,
"parts": parts
}));
}
}
}
// Post-process contents to merge consecutive turns with the same role
let mut merged_contents: Vec<Value> = Vec::new();
for item in contents {
if let Some(last_item) = merged_contents.last_mut() {
if last_item["role"] == item["role"] {
if let (Some(last_parts), Some(item_parts)) =
(last_item["parts"].as_array_mut(), item["parts"].as_array())
{
last_parts.extend(item_parts.clone());
continue;
}
}
}
merged_contents.push(item);
}
// Post-process to ensure all functionCalls have matching functionResponses.
// Gemini API requires all functionCalls to be resolved by functionResponses.
// If a turn was cancelled, the client might not send the tool result.
let mut resolved_ids = std::collections::HashSet::new();
for item in &merged_contents {
if let Some(parts_arr) = item["parts"].as_array() {
for part in parts_arr {
if let Some(resp) = part.get("functionResponse") {
if let Some(id) = resp.get("id").and_then(|v| v.as_str()) {
resolved_ids.insert(id.to_string());
}
}
}
}
}
// Now filter out any unresolved functionCalls from model parts
for item in &mut merged_contents {
if item["role"] == "model" {
if let Some(parts_arr) = item["parts"].as_array_mut() {
parts_arr.retain(|part| {
if let Some(call) = part.get("functionCall") {
if let Some(id) = call.get("id").and_then(|v| v.as_str()) {
return resolved_ids.contains(id);
}
}
true
});
}
}
}
// Filter out any empty model messages that resulted from stripping unresolved calls
merged_contents.retain(|item| {
if let Some(parts_arr) = item["parts"].as_array() {
!parts_arr.is_empty()
} else {
true
}
});
(merged_contents, None)
}
pub fn map_anthropic_tools_to_gemini(anthropic_tools: &Value) -> Option<Value> {
let tools_arr = anthropic_tools.as_array()?;
let mut declarations = Vec::new();
for tool in tools_arr {
declarations.push(make_function_declaration(
tool["name"].as_str().unwrap_or(""),
tool["description"].as_str().unwrap_or(""),
tool.get("input_schema"),
));
}
wrap_function_declarations(declarations)
}
pub fn map_anthropic_system_instruction(system: &Value) -> Option<Value> {
if system.is_string() {
let text = system.as_str().unwrap_or("");
if !text.is_empty() {
return Some(serde_json::json!({
"role": "user",
"parts": [{"text": text}]
}));
}
} else if let Some(arr) = system.as_array() {
let mut parts = Vec::new();
for item in arr {
if item["type"].as_str() == Some("text") {
if let Some(text) = item["text"].as_str() {
if !text.is_empty() {
parts.push(serde_json::json!({ "text": text }));
}
}
}
}
if !parts.is_empty() {
return Some(serde_json::json!({
"role": "user",
"parts": parts
}));
}
}
None
}