gemini / server /src /proxy /mappers /openai /request.rs
yinming
feat: Antigravity API Proxy for HuggingFace Spaces
bbb1195
// OpenAI → Gemini 请求转换
use super::models::*;
use serde_json::{json, Value};
use super::streaming::get_thought_signature;
pub fn transform_openai_request(request: &OpenAIRequest, project_id: &str, mapped_model: &str) -> Value {
// 将 OpenAI 工具转为 Value 数组以便探测
let tools_val = request.tools.as_ref().map(|list| {
list.iter().map(|v| v.clone()).collect::<Vec<_>>()
});
// Resolve grounding config
let config = crate::proxy::mappers::common_utils::resolve_request_config(&request.model, mapped_model, &tools_val);
tracing::info!("[Debug] OpenAI Request: original='{}', mapped='{}', type='{}', has_image_config={}",
request.model, mapped_model, config.request_type, config.image_config.is_some());
// 1. 提取所有 System Message 并注入补丁
let mut system_instructions: Vec<String> = request.messages.iter()
.filter(|msg| msg.role == "system")
.filter_map(|msg| {
msg.content.as_ref().map(|c| match c {
OpenAIContent::String(s) => s.clone(),
OpenAIContent::Array(blocks) => {
blocks.iter().filter_map(|b| {
if let OpenAIContentBlock::Text { text } = b {
Some(text.clone())
} else {
None
}
}).collect::<Vec<_>>().join("\n")
}
})
})
.collect();
// 注入 Codex/Coding Agent 补丁
system_instructions.push("You are a coding agent. You MUST use the provided 'shell' tool to perform ANY filesystem operations (reading, writing, creating files). Do not output JSON code blocks for tool execution; invoke the functions directly. To create a file, use the 'shell' tool with 'New-Item' or 'Set-Content' (Powershell). NEVER simulate/hallucinate actions in text without calling the tool first.".to_string());
// Pre-scan to map tool_call_id to function name (for Codex)
let mut tool_id_to_name = std::collections::HashMap::new();
for msg in &request.messages {
if let Some(tool_calls) = &msg.tool_calls {
for call in tool_calls {
let name = &call.function.name;
let final_name = if name == "local_shell_call" { "shell" } else { name };
tool_id_to_name.insert(call.id.clone(), final_name.to_string());
}
}
}
// 从全局存储获取 thoughtSignature (PR #93 支持)
let global_thought_sig = get_thought_signature();
if global_thought_sig.is_some() {
tracing::info!("从全局存储获取到 thoughtSignature (长度: {})", global_thought_sig.as_ref().unwrap().len());
}
// 2. 构建 Gemini contents (过滤掉 system)
let contents: Vec<Value> = request
.messages
.iter()
.filter(|msg| msg.role != "system")
.map(|msg| {
let role = match msg.role.as_str() {
"assistant" => "model",
"tool" | "function" => "user",
_ => &msg.role,
};
let mut parts = Vec::new();
// Handle content (multimodal or text)
if let Some(content) = &msg.content {
match content {
OpenAIContent::String(s) => {
if !s.is_empty() {
if role == "user" && mapped_model.contains("gemini-3") {
// 为 Gemini 3 用户消息添加提醒补丁
let reminder = "\n\n(SYSTEM REMINDER: You MUST use the 'shell' tool to perform this action. Do not simply state it is done.)";
parts.push(json!({"text": format!("{}{}", s, reminder)}));
} else {
parts.push(json!({"text": s}));
}
}
}
OpenAIContent::Array(blocks) => {
for block in blocks {
match block {
OpenAIContentBlock::Text { text } => {
if role == "user" && mapped_model.contains("gemini-3") {
let reminder = "\n\n(SYSTEM REMINDER: You MUST use the 'shell' tool to perform this action. Do not simply state it is done.)";
parts.push(json!({ "text": format!("{}{}", text, reminder) }));
} else {
parts.push(json!({"text": text}));
}
}
OpenAIContentBlock::ImageUrl { image_url } => {
if image_url.url.starts_with("data:") {
if let Some(pos) = image_url.url.find(",") {
let mime_part = &image_url.url[5..pos];
let mime_type = mime_part.split(';').next().unwrap_or("image/jpeg");
let data = &image_url.url[pos + 1..];
parts.push(json!({
"inlineData": { "mimeType": mime_type, "data": data }
}));
}
} else if image_url.url.starts_with("http") {
parts.push(json!({
"fileData": { "fileUri": &image_url.url, "mimeType": "image/jpeg" }
}));
} else {
// [NEW] 处理本地文件路径 (file:// 或 Windows/Unix 路径)
let file_path = if image_url.url.starts_with("file://") {
// 移除 file:// 前缀
#[cfg(target_os = "windows")]
{ image_url.url.trim_start_matches("file:///").replace('/', "\\") }
#[cfg(not(target_os = "windows"))]
{ image_url.url.trim_start_matches("file://").to_string() }
} else {
image_url.url.clone()
};
tracing::info!("[OpenAI-Request] Reading local image: {}", file_path);
// 读取文件并转换为 base64
if let Ok(file_bytes) = std::fs::read(&file_path) {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes);
// 根据文件扩展名推断 MIME 类型
let mime_type = if file_path.to_lowercase().ends_with(".png") {
"image/png"
} else if file_path.to_lowercase().ends_with(".gif") {
"image/gif"
} else if file_path.to_lowercase().ends_with(".webp") {
"image/webp"
} else {
"image/jpeg"
};
parts.push(json!({
"inlineData": { "mimeType": mime_type, "data": b64 }
}));
tracing::info!("[OpenAI-Request] Successfully loaded image: {} ({} bytes)", file_path, file_bytes.len());
} else {
tracing::warn!("[OpenAI-Request] Failed to read local image: {}", file_path);
}
}
}
}
}
}
}
}
// Handle tool calls (assistant message)
if let Some(tool_calls) = &msg.tool_calls {
for (index, tc) in tool_calls.iter().enumerate() {
/* 暂时移除:防止 Codex CLI 界面碎片化
if index == 0 && parts.is_empty() {
if mapped_model.contains("gemini-3") {
parts.push(json!({"text": "Thinking Process: Determining necessary tool actions."}));
}
}
*/
let args = serde_json::from_str::<Value>(&tc.function.arguments).unwrap_or(json!({}));
let mut func_call_part = json!({
"functionCall": {
"name": if tc.function.name == "local_shell_call" { "shell" } else { &tc.function.name },
"args": args
}
});
// [修复] 为该消息内的所有工具调用注入 thoughtSignature (PR #114 优化)
if let Some(ref sig) = global_thought_sig {
func_call_part["thoughtSignature"] = json!(sig);
}
parts.push(func_call_part);
}
}
// Handle tool response
if msg.role == "tool" || msg.role == "function" {
let name = msg.name.as_deref().unwrap_or("unknown");
let final_name = if name == "local_shell_call" { "shell" }
else if let Some(id) = &msg.tool_call_id { tool_id_to_name.get(id).map(|s| s.as_str()).unwrap_or(name) }
else { name };
let content_val = match &msg.content {
Some(OpenAIContent::String(s)) => s.clone(),
Some(OpenAIContent::Array(blocks)) => blocks.iter().filter_map(|b| if let OpenAIContentBlock::Text { text } = b { Some(text.clone()) } else { None }).collect::<Vec<_>>().join("\n"),
None => "".to_string()
};
parts.push(json!({
"functionResponse": {
"name": final_name,
"response": { "result": content_val }
}
}));
}
json!({ "role": role, "parts": parts })
})
.collect();
// [PR #合并] 合并连续相同角色的消息 (Gemini 强制要求 user/model 交替)
let mut merged_contents: Vec<Value> = Vec::new();
for msg in contents {
if let Some(last) = merged_contents.last_mut() {
if last["role"] == msg["role"] {
// 合并 parts
if let (Some(last_parts), Some(msg_parts)) = (last["parts"].as_array_mut(), msg["parts"].as_array()) {
last_parts.extend(msg_parts.iter().cloned());
continue;
}
}
}
merged_contents.push(msg);
}
let contents = merged_contents;
// 3. 构建请求体
let mut gen_config = json!({
"maxOutputTokens": request.max_tokens.unwrap_or(64000),
"temperature": request.temperature.unwrap_or(1.0),
"topP": request.top_p.unwrap_or(1.0),
});
if let Some(stop) = &request.stop {
if stop.is_string() { gen_config["stopSequences"] = json!([stop]); }
else if stop.is_array() { gen_config["stopSequences"] = stop.clone(); }
}
if let Some(fmt) = &request.response_format {
if fmt.r#type == "json_object" {
gen_config["responseMimeType"] = json!("application/json");
}
}
let mut inner_request = json!({
"contents": contents,
"generationConfig": gen_config,
"safetySettings": [
{ "category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF" },
{ "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF" },
{ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF" },
{ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF" },
{ "category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF" },
]
});
// 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入)
crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request);
// 4. Handle Tools (Merged Cleaning)
if let Some(tools) = &request.tools {
let mut function_declarations: Vec<Value> = Vec::new();
for tool in tools.iter() {
let mut gemini_func = if let Some(func) = tool.get("function") {
func.clone()
} else {
let mut func = tool.clone();
if let Some(obj) = func.as_object_mut() {
obj.remove("type");
obj.remove("strict");
obj.remove("additionalProperties");
}
func
};
if let Some(name) = gemini_func.get("name").and_then(|v| v.as_str()) {
// 跳过内置联网工具名称,避免重复定义
if name == "web_search" || name == "google_search" || name == "web_search_20250305" {
continue;
}
if name == "local_shell_call" {
if let Some(obj) = gemini_func.as_object_mut() {
obj.insert("name".to_string(), json!("shell"));
}
}
}
// [NEW CRITICAL FIX] 清除函数定义根层级的非法字段 (解决报错持久化)
if let Some(obj) = gemini_func.as_object_mut() {
obj.remove("format");
obj.remove("strict");
obj.remove("additionalProperties");
obj.remove("type"); // [NEW] Gemini 不支持在 FunctionDeclaration 根层级出现 type: "function"
}
if let Some(params) = gemini_func.get_mut("parameters") {
// [DEEP FIX] 统一调用公共库清洗:展开 $ref 并剔除所有层级的 format/definitions
crate::proxy::common::json_schema::clean_json_schema(params);
// Gemini v1internal 要求:
// 1. type 必须是大写 (OBJECT, STRING 等)
// 2. 根对象必须有 "type": "OBJECT"
if let Some(params_obj) = params.as_object_mut() {
if !params_obj.contains_key("type") {
params_obj.insert("type".to_string(), json!("OBJECT"));
}
}
// 递归转换 type 为大写 (符合 Protobuf 定义)
enforce_uppercase_types(params);
}
function_declarations.push(gemini_func);
}
if !function_declarations.is_empty() {
inner_request["tools"] = json!([{ "functionDeclarations": function_declarations }]);
}
}
if !system_instructions.is_empty() {
inner_request["systemInstruction"] = json!({ "parts": [{"text": system_instructions.join("\n\n")}] });
}
if config.inject_google_search {
crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request);
}
if let Some(image_config) = config.image_config {
if let Some(obj) = inner_request.as_object_mut() {
obj.remove("tools");
obj.remove("systemInstruction");
let gen_config = obj.entry("generationConfig").or_insert_with(|| json!({}));
if let Some(gen_obj) = gen_config.as_object_mut() {
gen_obj.remove("thinkingConfig");
gen_obj.remove("responseMimeType");
gen_obj.remove("responseModalities");
gen_obj.insert("imageConfig".to_string(), image_config);
}
}
}
json!({
"project": project_id,
"requestId": format!("openai-{}", uuid::Uuid::new_v4()),
"request": inner_request,
"model": config.final_model,
"userAgent": "antigravity",
"requestType": config.request_type
})
}
fn enforce_uppercase_types(value: &mut Value) {
if let Value::Object(map) = value {
if let Some(type_val) = map.get_mut("type") {
if let Value::String(ref mut s) = type_val {
*s = s.to_uppercase();
}
}
if let Some(properties) = map.get_mut("properties") {
if let Value::Object(ref mut props) = properties {
for v in props.values_mut() {
enforce_uppercase_types(v);
}
}
}
if let Some(items) = map.get_mut("items") {
enforce_uppercase_types(items);
}
} else if let Value::Array(arr) = value {
for item in arr {
enforce_uppercase_types(item);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transform_openai_request_multimodal() {
let req = OpenAIRequest {
model: "gpt-4-vision".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::Array(vec![
OpenAIContentBlock::Text { text: "What is in this image?".to_string() },
OpenAIContentBlock::ImageUrl { image_url: OpenAIImageUrl {
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==".to_string(),
detail: None
} }
])),
tool_calls: None,
tool_call_id: None,
name: None,
}],
stream: false,
max_tokens: None,
temperature: None,
top_p: None,
stop: None,
response_format: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
instructions: None,
input: None,
prompt: None,
};
let result = transform_openai_request(&req, "test-v", "gemini-1.5-flash");
let parts = &result["request"]["contents"][0]["parts"];
assert_eq!(parts.as_array().unwrap().len(), 2);
assert_eq!(parts[0]["text"].as_str().unwrap(), "What is in this image?");
assert_eq!(parts[1]["inlineData"]["mimeType"].as_str().unwrap(), "image/png");
}
}