File size: 53,870 Bytes
a21c316 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 | // OpenAI → Gemini 请求转换
use super::models::*;
use crate::proxy::model_specs;
use crate::proxy::token_manager::ProxyToken;
use serde_json::{json, Value};
pub fn transform_openai_request(
request: &OpenAIRequest,
project_id: &str,
mapped_model: &str,
token: Option<&ProxyToken>,
) -> (Value, String, usize) {
let session_id = crate::proxy::session_manager::SessionManager::extract_openai_session_id(request);
let message_count = request.messages.len();
// 将 OpenAI 工具转为 Value 数组以便探测
let tools_val = request
.tools
.as_ref()
.map(|list| list.iter().map(|v| v.clone()).collect::<Vec<_>>());
let mapped_model_lower = mapped_model.to_lowercase();
// Resolve grounding config
let config = crate::proxy::mappers::common_utils::resolve_request_config(
&request.model,
&mapped_model_lower,
&tools_val,
request.size.as_deref(), // [NEW] Pass size parameter
request.quality.as_deref(), // [NEW] Pass quality parameter
request.image_size.as_deref(), // [FIX] Pass imageSize parameter
None, // body
);
// [FIX] 仅当模型名称显式包含 "-thinking" 时才视为 Gemini 思维模型
// 避免对 gemini-3-pro (preview) 等其实不支持 thinkingConfig 的模型注入参数导致 400
// [FIX #1557] Allow "pro" models (e.g. gemini-3-pro, gemini-2.0-pro) to bypass thinking check
// These models support thinking but do not have "-thinking" suffix
let is_gemini_3_thinking = mapped_model_lower.contains("gemini")
&& (
mapped_model_lower.contains("-thinking")
|| mapped_model_lower.contains("gemini-2.0-pro")
|| mapped_model_lower.contains("gemini-3-pro")
|| mapped_model_lower.contains("gemini-3.1-pro")
)
&& !mapped_model_lower.contains("claude");
// [FIX #2167] gemini-3-flash / gemini-3.1-flash 支持 thinking,functionCall 必须携带 thoughtSignature
// [FEATURE] 同时注入 includeThoughts:true 使 Gemini 返回 thought:true chunk,客户端可显示思维链
let is_gemini_flash_thinking = (mapped_model_lower.contains("gemini-3-flash")
|| mapped_model_lower.contains("gemini-3.1-flash"))
&& !mapped_model_lower.contains("claude");
let is_claude_thinking = mapped_model_lower.ends_with("-thinking");
let is_thinking_model = is_gemini_3_thinking || is_claude_thinking || is_gemini_flash_thinking;
// [NEW] 检查用户是否在请求中显式启用 thinking
let user_enabled_thinking = request.thinking.as_ref()
.map(|t| t.thinking_type.as_deref() == Some("enabled"))
.unwrap_or(false);
let user_thinking_budget = request.thinking.as_ref()
.and_then(|t| t.budget_tokens);
// [NEW] 检查历史消息是否兼容思维模型 (是否有 Assistant 消息缺失 reasoning_content)
let has_incompatible_assistant_history = request.messages.iter().any(|msg| {
msg.role == "assistant"
&& msg
.reasoning_content
.as_ref()
.map(|s| s.is_empty())
.unwrap_or(true)
});
let has_tool_history = request.messages.iter().any(|msg| {
msg.role == "tool" || msg.role == "function" || msg.tool_calls.is_some()
});
// [NEW] 决定是否开启 Thinking 功能:
// 1. 模型名包含 -thinking 时自动开启
// 2. 用户在请求中显式设置 thinking.type = "enabled" 时开启
// 如果是 Claude 思考模型且历史不兼容且没有可用签名来占位, 则禁用 Thinking 以防 400
let mut actual_include_thinking = is_thinking_model || user_enabled_thinking;
// [REFACTORED] 使用 SignatureCache 获取 Session 级别的签名
let session_thought_sig = crate::proxy::SignatureCache::global().get_session_signature(&session_id);
if is_claude_thinking && has_incompatible_assistant_history && session_thought_sig.is_none() {
tracing::warn!("[OpenAI-Thinking] Incompatible assistant history detected for Claude thinking model without session signature. Disabling thinking for this request to avoid 400 error. (sid: {})", session_id);
actual_include_thinking = false;
}
// [NEW] 日志:用户显式设置 thinking
if user_enabled_thinking {
tracing::info!(
"[OpenAI-Thinking] User explicitly enabled thinking with budget: {:?}",
user_thinking_budget
);
}
tracing::debug!(
"[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" || msg.role == "developer")
.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();
// [NEW] 如果请求中包含 instructions 字段,优先使用它
if let Some(inst) = &request.instructions {
if !inst.is_empty() {
system_instructions.insert(0, inst.clone());
}
}
// 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());
}
}
}
// 从缓存获取当前会话的思维签名
let thought_sig = session_thought_sig;
if thought_sig.is_some() {
tracing::debug!(
"[OpenAI-Request] Using session signature (sid: {}, len: {})",
session_id,
thought_sig.as_ref().unwrap().len()
);
}
// [New] 预先构建工具名称到原始 Schema 的映射,用于后续参数类型修正
let mut tool_name_to_schema = std::collections::HashMap::new();
if let Some(tools) = &request.tools {
for tool in tools {
if let (Some(name), Some(params)) = (
tool.get("function")
.and_then(|f| f.get("name"))
.and_then(|v| v.as_str()),
tool.get("function").and_then(|f| f.get("parameters")),
) {
tool_name_to_schema.insert(name.to_string(), params.clone());
} else if let (Some(name), Some(params)) = (
tool.get("name").and_then(|v| v.as_str()),
tool.get("parameters"),
) {
// 处理某些客户端可能透传的精简格式
tool_name_to_schema.insert(name.to_string(), params.clone());
}
}
}
// 2. 构建 Gemini contents (过滤掉 system/developer 指令)
let contents: Vec<Value> = request
.messages
.iter()
.filter(|msg| msg.role != "system" && msg.role != "developer")
.map(|msg| {
let role = match msg.role.as_str() {
"assistant" => "model",
"tool" | "function" => "user",
_ => &msg.role,
};
let mut parts = Vec::new();
// Handle reasoning_content (thinking)
if let Some(reasoning) = &msg.reasoning_content {
// [FIX #1506] 增强对占位符 [undefined] 的识别
let is_invalid_placeholder = reasoning == "[undefined]" || reasoning.is_empty();
if !is_invalid_placeholder {
let thought_part = json!({
"text": reasoning,
"thought": true,
});
parts.push(thought_part);
}
} else if actual_include_thinking && role == "model" {
// [FIX] 解决 Claude 4.6 Thinking 模型的强制性校验:
// "Expected thinking... but found tool_use/text"
// 如果是思维模型且缺失 reasoning_content, 则注入占位符
tracing::debug!("[OpenAI-Thinking] Injecting placeholder thinking block for assistant message");
let mut thought_part = json!({
"text": "Applying tool decisions and generating response...",
"thought": true,
});
// [FIX #1575] 占位符永远不能使用真实签名(签名与真实思考内容绑定)
// 仅 Gemini 支持哨兵值跳过验证
if is_gemini_3_thinking {
thought_part["thoughtSignature"] = json!("skip_thought_signature_validator");
}
parts.push(thought_part);
}
// Handle content (multimodal or text)
// [FIX] Skip standard content mapping for tool/function roles to avoid duplicate parts
// These are handled below in the "Handle tool response" section.
let is_tool_role = msg.role == "tool" || msg.role == "function";
if let (Some(content), false) = (&msg.content, is_tool_role) {
match content {
OpenAIContent::String(s) => {
if !s.is_empty() {
parts.push(json!({"text": s}));
}
}
OpenAIContent::Array(blocks) => {
for block in blocks {
match block {
OpenAIContentBlock::Text { text } => {
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::debug!("[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::debug!("[OpenAI-Request] Successfully loaded image: {} ({} bytes)", file_path, file_bytes.len());
} else {
tracing::debug!("[OpenAI-Request] Failed to read local image: {}", file_path);
}
}
}
OpenAIContentBlock::AudioUrl { audio_url: _ } => {
// 暂时跳过 audio_url 处理
// 完整实现需要下载音频文件并转换为 Gemini inlineData 格式
// 这会与 v3.3.16 的 thinkingConfig 逻辑冲突,留待后续版本实现
tracing::debug!("[OpenAI-Request] Skipping audio_url (not yet implemented in v3.3.16)");
}
}
}
}
}
}
// 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 mut args = serde_json::from_str::<Value>(&tc.function.arguments).unwrap_or(json!({}));
// [New] 利用通用引擎修正参数类型 (替代以前硬编码的 shell 工具修复逻辑)
if let Some(original_schema) = tool_name_to_schema.get(&tc.function.name) {
crate::proxy::common::json_schema::fix_tool_call_args(&mut args, original_schema);
}
let mut func_call_part = json!({
"functionCall": {
"name": if tc.function.name == "local_shell_call" { "shell" } else { &tc.function.name },
"args": args,
"id": &tc.id,
}
});
// [New] 递归清理参数中可能存在的非法校验字段
crate::proxy::common::json_schema::clean_json_schema(&mut func_call_part);
if let Some(ref sig) = thought_sig {
func_call_part["thoughtSignature"] = json!(sig);
} else if is_thinking_model || is_gemini_flash_thinking {
// [NEW] Handle missing signature for Gemini thinking models
// [FIX #1650] Allow sentinel injection for Vertex AI (projects/...) as well
// [FIX #2167] Also applies to gemini-3-flash / gemini-3.1-flash
tracing::debug!("[OpenAI-Signature] Adding GEMINI_SKIP_SIGNATURE for tool_use: {}", tc.id);
func_call_part["thoughtSignature"] = json!("skip_thought_signature_validator");
}
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 mut extra_parts = Vec::new();
let content_val = match &msg.content {
Some(OpenAIContent::String(s)) => s.clone(),
Some(OpenAIContent::Array(blocks)) => {
let mut texts = Vec::new();
for block in blocks {
match block {
OpenAIContentBlock::Text { text } => texts.push(text.clone()),
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..];
extra_parts.push(json!({
"inlineData": { "mimeType": mime_type, "data": data }
}));
}
} else {
texts.push("[image link]".to_string());
}
}
_ => {}
}
}
texts.join("\n")
},
None => "".to_string()
};
parts.push(json!({
"functionResponse": {
"name": final_name,
"response": { "result": content_val },
"id": msg.tool_call_id.clone().unwrap_or_default()
}
}));
for extra in extra_parts {
parts.push(extra);
}
}
json!({ "role": role, "parts": parts })
})
.filter(|msg| !msg["parts"].as_array().map(|a| a.is_empty()).unwrap_or(true))
.collect();
// [FIX #1575] 针对思维模型的历史故障恢复
// 在带有工具的历史记录中,剥离旧的思考块,防止 API 因签名失效或结构冲突报 400
let mut contents = contents;
if actual_include_thinking && has_tool_history {
tracing::debug!("[OpenAI-Thinking] Applied thinking recovery (stripping old thought blocks) for tool history");
contents = super::thinking_recovery::strip_all_thinking_blocks(contents);
}
// 合并连续相同角色的消息 (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!({
"temperature": request.temperature.unwrap_or(1.0),
// [CHANGED v4.1.24] Default topP from 0.95 → 1.0 to match native behavior
"topP": request.top_p.unwrap_or(1.0),
// [ADDED v4.1.24] topK=40 aligns with official client generationConfig
"topK": 40,
});
// [FIX] 移除旧的硬编码限额,改为动态查询 (v4.1.29)
if let Some(max_tokens) = request.max_tokens {
gen_config["maxOutputTokens"] = json!(max_tokens);
} else {
// 使用动态优先的规格限额
let limit = model_specs::get_max_output_tokens(mapped_model, token);
gen_config["maxOutputTokens"] = json!(limit);
}
// [NEW] 支持多候选结果数量 (n -> candidateCount)
if let Some(n) = request.n {
gen_config["candidateCount"] = json!(n);
}
// 为 thinking 模型注入 thinkingConfig (使用 thinkingBudget 而非 thinkingLevel)
if actual_include_thinking {
// [RESOLVE #1694] Check image thinking mode
let image_thinking_mode = crate::proxy::config::get_image_thinking_mode();
// Only disable if mode is explicitly "disabled" AND it's an image generation request
let is_image_gen_disabled = config.request_type == "image_gen" && image_thinking_mode == "disabled";
if is_image_gen_disabled {
tracing::debug!("[OpenAI-Request] Image thinking mode disabled: enforcing includeThoughts=false for {}", mapped_model);
gen_config["thinkingConfig"] = json!({
"includeThoughts": false
});
} else {
// [CONFIGURABLE] 根据配置和模型规格决定 thinking_budget (v4.1.29)
let tb_config = crate::proxy::config::get_thinking_budget_config();
// 优先使用用户在请求中传入的 budget,否则从规格表中获取默认值
let default_budget = model_specs::get_thinking_budget(mapped_model, token);
let user_budget: i64 = user_thinking_budget.map(|b| b as i64).unwrap_or(default_budget as i64);
let budget = match tb_config.mode {
crate::proxy::config::ThinkingBudgetMode::Passthrough => {
user_budget
}
crate::proxy::config::ThinkingBudgetMode::Custom => {
let mut custom_value = tb_config.custom_value as i64;
// 如果自定义值超过了模型规格上限,则进行裁剪
if custom_value > default_budget as i64 {
tracing::warn!(
"[OpenAI-Request] Custom budget {} exceeds model spec limit {}, capping.",
custom_value, default_budget
);
custom_value = default_budget as i64;
}
custom_value
}
crate::proxy::config::ThinkingBudgetMode::Auto => {
// Auto 模式下,直接应用规格建议的预算
if user_budget > default_budget as i64 {
default_budget as i64
} else {
user_budget
}
}
crate::proxy::config::ThinkingBudgetMode::Adaptive => {
user_budget
}
};
gen_config["thinkingConfig"] = json!({
"includeThoughts": true,
"thinkingBudget": budget
});
// [CRITICAL] 思维模型的 maxOutputTokens 必须大于 thinkingBudget
// [FIX #1675] 针对图像模型使用更保守的 max_tokens 增量,避免触发 128k 限制
let overhead = if config.request_type == "image_gen" { 2048 } else { 32768 };
let min_overhead = if config.request_type == "image_gen" { 1024 } else { 8192 };
if let Some(max_tokens) = request.max_tokens {
if (max_tokens as i64) <= budget {
gen_config["maxOutputTokens"] = json!(budget + min_overhead);
}
} else {
// [FIX #1592] Use a more conservative default to avoid 400 error on 128k context models
gen_config["maxOutputTokens"] = json!(budget + overhead);
}
let new_max = gen_config["maxOutputTokens"].as_i64().unwrap_or(0);
tracing::debug!(
"[OpenAI-Request] Adjusted maxOutputTokens to {} for thinking model (budget={})",
new_max, budget
);
tracing::debug!(
"[OpenAI-Request] Injected thinkingConfig for model {}: thinkingBudget={} (mode={:?})",
mapped_model, budget, tb_config.mode
);
}
}
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, 0);
// 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
};
let name_opt = gemini_func.get("name").and_then(|v| v.as_str()).map(|s| s.to_string());
if let Some(name) = &name_opt {
// 跳过内置联网工具名称,避免重复定义
if name == "web_search"
|| name == "google_search"
|| name == "web_search_20250305"
|| name == "builtin_web_search"
{
continue;
}
if name == "local_shell_call" {
if let Some(obj) = gemini_func.as_object_mut() {
obj.insert("name".to_string(), json!("shell"));
}
}
} else {
// [FIX] 如果工具没有名称,视为无效工具直接跳过 (防止 REQUIRED_FIELD_MISSING)
tracing::warn!("[OpenAI-Request] Skipping tool without name: {:?}", gemini_func);
continue;
}
// [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"
obj.remove("external_web_access"); // [FIX #1278] Remove invalid field injected by OpenAI Codex
}
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);
} else {
// [FIX] 针对自定义工具 (如 apply_patch) 补全缺失的参数模式
// 解决 Vertex AI (Claude) 报错: tools.5.custom.input_schema: Field required
tracing::debug!(
"[OpenAI-Request] Injecting default schema for custom tool: {}",
gemini_func
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
);
gemini_func.as_object_mut().unwrap().insert(
"parameters".to_string(),
json!({
"type": "OBJECT",
"properties": {
"content": {
"type": "STRING",
"description": "The raw content or patch to be applied"
}
},
"required": ["content"]
}),
);
}
function_declarations.push(gemini_func);
}
if !function_declarations.is_empty() {
inner_request["tools"] = json!([{ "functionDeclarations": function_declarations }]);
// [ADDED v4.1.24] toolConfig VALIDATED - aligns with native behavior
inner_request["toolConfig"] = json!({
"functionCallingConfig": { "mode": "VALIDATED" }
});
}
}
// [NEW] Antigravity 身份指令 (原始简化版)
let antigravity_identity = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\n\
You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n\
**Absolute paths only**\n\
**Proactiveness**";
// [HYBRID] 检查用户是否已提供 Antigravity 身份
let user_has_antigravity = system_instructions
.iter()
.any(|s| s.contains("You are Antigravity"));
let mut parts = Vec::new();
// 1. Antigravity 身份 (如果需要, 作为独立 Part 插入)
if !user_has_antigravity {
parts.push(json!({"text": antigravity_identity}));
}
// 2. [NEW] 注入全局系统提示词 (紧跟 Antigravity 身份之后)
let global_prompt_config = crate::proxy::config::get_global_system_prompt();
if global_prompt_config.enabled && !global_prompt_config.content.trim().is_empty() {
parts.push(json!({"text": global_prompt_config.content}));
}
// 3. 追加用户指令 (作为独立 Parts)
for inst in system_instructions {
parts.push(json!({"text": inst}));
}
inner_request["systemInstruction"] = json!({
"role": "user",
"parts": parts
});
if config.inject_google_search {
crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request, Some(mapped_model));
}
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() {
// [REMOVED] thinkingConfig 拦截已删除,允许图像生成时输出思维链
// gen_obj.remove("thinkingConfig");
gen_obj.remove("responseMimeType");
gen_obj.remove("responseModalities");
gen_obj.insert("imageConfig".to_string(), image_config);
}
}
}
// [ADDED v4.1.24] 注入稳定 sessionId 对齐官方规范
if let Some(t) = token {
inner_request["sessionId"] = json!(crate::proxy::common::session::derive_session_id(&t.account_id));
}
let final_body = json!({
"project": project_id,
// [CHANGED v4.1.24] Structured requestId: agent/<session>/<turn> to match official format
"requestId": format!("agent/antigravity/{}/{}", &session_id[..session_id.len().min(8)], message_count),
"request": inner_request,
"model": config.final_model,
"userAgent": "antigravity",
// [CHANGED v4.1.24] Use "agent" for all non-image requests (matches official client)
"requestType": if config.request_type == "image_gen" { "image_gen" } else { "agent" }
});
(final_body, session_id, message_count)
}
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::*;
use crate::proxy::mappers::openai::models::*;
#[test]
#[test]
fn test_issue_1592_gemini_3_pro_budget_capping() {
// [FIX #1592] Regression test for gemini-3-pro thinking budget capping
let req = OpenAIRequest {
model: "gemini-3-pro".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("test".into())),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
..Default::default()
};
// Auto mode (default) should cap gemini-3-pro thinking budget to 24576
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-v", "gemini-3-pro", None);
let budget = result["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"]
.as_i64()
.unwrap();
assert_eq!(budget, 24576, "Gemini-3-pro budget must be capped to 24576 in Auto mode");
}
#[test]
fn test_issue_1602_custom_mode_gemini_capping() {
// [FIX #1602] Regression test for custom mode capping
use crate::proxy::config::{ThinkingBudgetConfig, ThinkingBudgetMode, update_thinking_budget_config};
// 设置自定义模式,且数值超过 24k
update_thinking_budget_config(ThinkingBudgetConfig {
mode: ThinkingBudgetMode::Custom,
custom_value: 32000,
effort: None,
});
let req = OpenAIRequest {
model: "gemini-2.0-flash-thinking".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("test".into())),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
stream: false,
n: None,
max_tokens: None,
temperature: None,
top_p: None,
stop: None,
response_format: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
..Default::default()
};
// 验证针对 Gemini 模型即使是 Custom 模式也会被修正为 24576
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-v", "gemini-2.0-flash-thinking", None);
let budget = result["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"]
.as_i64()
.unwrap();
assert_eq!(budget, 24576, "Gemini custom budget must be capped to 24576");
// 验证非 Gemini 模型(如 Claude 原生路径,假设映射后名不含 gemini)则不应截断
// 注意:这里的 transform_openai_request 第三个参数是 mapped_model
let (result_claude, _, _) = transform_openai_request(&req, "test-v", "claude-3-7-sonnet", None);
let budget_claude = result_claude["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"]
.as_i64();
// 如果不是 gemini 模型且协议中没带 thinking 配置,可能会是 None 或 32000
// 在该测试环境下,由于模拟的是 OpenAI 格式转 Gemini 路径,如果没有 gemini 关键词通常不进入 thinking 逻辑
// 我们只需确保 gemini 路径正确受限即可。
// 恢复默认配置
update_thinking_budget_config(ThinkingBudgetConfig::default());
}
#[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
} }
])),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
stream: false,
n: None,
max_tokens: None,
temperature: None,
top_p: None,
stop: None,
response_format: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
..Default::default()
};
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-v", "gemini-1.5-flash", None);
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"
);
}
#[test]
fn test_gemini_pro_thinking_injection() {
let req = OpenAIRequest {
model: "gemini-3-pro-preview".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("Thinking test".to_string())),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
stream: false,
n: None,
// User enabled thinking
thinking: Some(ThinkingConfig {
thinking_type: Some("enabled".to_string()),
budget_tokens: Some(16000),
effort: None,
}),
max_tokens: None,
temperature: None,
top_p: None,
stop: None,
response_format: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
..Default::default()
};
// Pass explicit gemini-3-pro-preview which doesn't have "-thinking" suffix
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-p", "gemini-3-pro-preview", None);
let gen_config = &result["request"]["generationConfig"];
// Assert thinkingConfig is present (fix verification)
assert!(gen_config.get("thinkingConfig").is_some(), "thinkingConfig should be injected for gemini-3-pro");
let budget = gen_config["thinkingConfig"]["thinkingBudget"].as_u64().unwrap();
// Should use user budget (16000) or capped valid default
assert_eq!(budget, 16000);
}
#[test]
fn test_gemini_3_pro_image_not_thinking() {
let req = OpenAIRequest {
model: "gemini-3-pro-image-4k".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("Generate a cat".to_string())),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
..Default::default()
};
// Pass gemini-3-pro-image which matches "gemini-3-pro" substring
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-p", "gemini-3-pro-image", None);
let gen_config = &result["request"]["generationConfig"];
// Assert thinkingConfig IS present (based on latest user feedback)
assert!(gen_config.get("thinkingConfig").is_some(), "thinkingConfig SHOULD be injected for gemini-3-pro-image");
// Assert imageConfig is present
assert!(gen_config.get("imageConfig").is_some(), "imageConfig should be present for image models");
assert_eq!(gen_config["imageConfig"]["imageSize"], "4K");
}
#[test]
fn test_default_max_tokens_openai() {
let req = OpenAIRequest {
model: "gpt-4".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("Hello".to_string())),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
stream: false,
n: None,
max_tokens: None,
temperature: None,
top_p: None,
stop: None,
response_format: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
..Default::default()
};
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-p", "gemini-3-pro-high-thinking", None);
let gen_config = &result["request"]["generationConfig"];
let max_output_tokens = gen_config["maxOutputTokens"].as_i64().unwrap();
// budget(24576) + overhead(32768) = 57344
assert_eq!(max_output_tokens, 57344);
// Verify thinkingBudget
let budget = gen_config["thinkingConfig"]["thinkingBudget"].as_i64().unwrap();
// actual(24576)
assert_eq!(budget, 24576);
}
#[test]
fn test_flash_thinking_budget_capping() {
let req = OpenAIRequest {
model: "gpt-4".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("Hello".to_string())),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
stream: false,
n: None,
// User specifies a large budget (e.g. xhigh = 32768)
thinking: Some(ThinkingConfig {
thinking_type: Some("enabled".to_string()),
budget_tokens: Some(32768),
effort: None,
}),
max_tokens: None,
temperature: None,
top_p: None,
stop: None,
response_format: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
..Default::default()
};
// Test with Flash model
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-p", "gemini-2.0-flash-thinking-exp", None);
let gen_config = &result["request"]["generationConfig"];
// Should be capped at 24576
let budget = gen_config["thinkingConfig"]["thinkingBudget"].as_i64().unwrap();
assert_eq!(budget, 24576);
// Max output tokens should be adjusted based on capped budget (24576 + 8192)
// budget(24576) + overhead(32768) = 57344
let max_output_tokens = gen_config["maxOutputTokens"].as_i64().unwrap();
assert_eq!(max_output_tokens, 57344);
}
#[test]
fn test_vertex_ai_sentinel_injection() {
// [FIX #1650] Verify sentinel signature injection for Vertex AI models
let req = OpenAIRequest {
model: "claude-3-7-sonnet-thinking".to_string(), // Triggers is_thinking_model
messages: vec![OpenAIMessage {
role: "assistant".to_string(),
content: None,
reasoning_content: Some("Thinking...".to_string()),
tool_calls: Some(vec![ToolCall {
id: "call_123".to_string(),
r#type: "function".to_string(),
function: ToolFunction {
name: "test_tool".to_string(),
arguments: "{}".to_string(),
},
}]),
tool_call_id: None,
name: None,
}],
person_generation: None,
..Default::default()
};
// Simulate Vertex AI path
let mapped_model = "projects/my-project/locations/us-central1/publishers/google/models/gemini-2.0-flash-thinking-exp";
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-v", mapped_model, None);
// Extract the tool call part from contents
let contents = result["contents"].as_array().unwrap();
// Identify the part with functionCall
let parts = contents[0]["parts"].as_array().unwrap();
let tool_part = parts.iter().find(|p| p.get("functionCall").is_some()).expect("Should find functionCall part");
// Vertex AI requires sentinel
assert_eq!(tool_part["thoughtSignature"].as_str(), Some("skip_thought_signature_validator"));
}
#[test]
fn test_issue_2167_gemini_flash_thinking_signature() {
// [FIX #2167] gemini-3-flash / gemini-3.1-flash 在无缓存签名时,functionCall 必须携带 thoughtSignature
for model in &["gemini-3-flash", "gemini-3.1-flash"] {
let req = OpenAIRequest {
model: model.to_string(),
messages: vec![OpenAIMessage {
role: "assistant".to_string(),
content: None,
reasoning_content: None, // 无 reasoning_content,模拟无缓存首次调用
tool_calls: Some(vec![ToolCall {
id: "call_flash_test".to_string(),
r#type: "function".to_string(),
function: ToolFunction {
name: "get_weather".to_string(),
arguments: "{\"location\":\"Beijing\"}".to_string(),
},
}]),
tool_call_id: None,
name: None,
}],
..Default::default()
};
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-proj", model, None);
let contents = result["request"]["contents"].as_array().expect("Should have request.contents");
// flash 模型的 assistant role → Gemini "model" role
let model_msg = contents.iter().find(|c| c["role"] == "model").expect("Should find model role message");
let parts = model_msg["parts"].as_array().expect("Should have parts");
let tool_part = parts
.iter()
.find(|p| p.get("functionCall").is_some())
.expect(&format!("[{model}] Should find functionCall part"));
assert_eq!(
tool_part["thoughtSignature"].as_str(),
Some("skip_thought_signature_validator"),
"[{model}] gemini-3-flash functionCall must contain thoughtSignature sentinel"
);
}
}
#[test]
fn test_openai_image_thinking_mode_disabled() {
// 1. Set global mode to disabled
crate::proxy::config::update_image_thinking_mode(Some("disabled".to_string()));
let req = OpenAIRequest {
model: "gemini-3-pro-image".to_string(),
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("Draw a cat".to_string())),
name: None,
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
}],
tools: None,
tool_choice: None,
parallel_tool_calls: None,
person_generation: None,
..Default::default()
};
// 2. Transform request
let (result, _sid, _msg_count) = transform_openai_request(&req, "test-proj", "gemini-3-pro-image", None);
// 3. Verify thinkingConfig has includeThoughts: false
let gen_config = result["request"]["generationConfig"].as_object().expect("Should have generationConfig in request payload");
let thinking_config = gen_config["thinkingConfig"].as_object().unwrap();
assert_eq!(thinking_config["includeThoughts"], false);
// 4. Reset global mode
crate::proxy::config::update_image_thinking_mode(Some("enabled".to_string()));
}
#[test]
fn test_mixed_tools_injection_openai() {
// 验证 OpenAI 协议在 Gemini 2.0+ 下支持混合工具
let req = OpenAIRequest {
model: "gpt-4o-online".to_string(), // -online 触发联网
messages: vec![OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::String("Hello".to_string())),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
name: None,
}],
tools: Some(vec![json!({
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"}
}
}
}
})]),
..Default::default()
};
// 使用 gemini-2.0-flash 模型执行转换
let (result, _, _) = transform_openai_request(&req, "proj", "gemini-2.0-flash", None);
let tools = result["request"]["tools"].as_array().expect("Should have tools");
let has_functions = tools.iter().any(|t| t.get("functionDeclarations").is_some());
let has_google_search = tools.iter().any(|t| t.get("googleSearch").is_some());
assert!(has_functions, "Should contain functionDeclarations");
assert!(has_google_search, "Should contain googleSearch (Gemini 2.0+ supports mixed tools)");
}
}
|