fix(formats): gate mixed Gemini tools by model

This commit is contained in:
ZheFox
2026-08-28 20:42:49 +08:00
parent f0b0064f3d
commit 83098f98b6
6 changed files with 225 additions and 18 deletions
@@ -9,7 +9,9 @@ use crate::{
map_openai_reasoning_effort_to_gemini_budget,
map_thinking_budget_to_openai_reasoning_effort,
},
shared::model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
shared::model_directives::{
gemini_model_supports_mixed_tools, gemini_model_uses_thinking_level, ReasoningEffort,
},
},
protocol::canonical::{
apply_gemini_request_extensions, canonical_extension_object_mut,
@@ -188,7 +190,7 @@ pub fn to_raw(
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
apply_gemini_request_extensions(&mut output, &canonical.extensions)?;
if !canonical_has_raw_gemini_tools(canonical) {
enable_server_side_tool_invocations_for_mixed_tools(&mut output)?;
enable_server_side_tool_invocations_for_mixed_tools(&mut output, mapped_model)?;
}
Some(output)
}
@@ -201,12 +203,41 @@ fn canonical_has_raw_gemini_tools(canonical: &CanonicalRequest) -> bool {
.is_some_and(|gemini| gemini.contains_key("raw_tools"))
}
fn enable_server_side_tool_invocations_for_mixed_tools(output: &mut Value) -> Option<()> {
fn enable_server_side_tool_invocations_for_mixed_tools(
output: &mut Value,
mapped_model: &str,
) -> Option<()> {
let output_object = output.as_object_mut()?;
let tools = output_object.get("tools").and_then(Value::as_array);
let Some(tools) = tools else {
return Some(());
};
if !gemini_tools_are_mixed(tools) {
return Some(());
}
if !gemini_model_supports_mixed_tools(mapped_model) {
return None;
}
let tool_config = output_object
.entry("toolConfig".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
tool_config.remove("include_server_side_tool_invocations");
tool_config.insert(
"includeServerSideToolInvocations".to_string(),
Value::Bool(true),
);
Some(())
}
pub(crate) fn canonical_has_mixed_gemini_tools(canonical: &CanonicalRequest) -> bool {
canonical_tools_to_gemini(canonical)
.and_then(|tools| tools.as_array().cloned())
.is_some_and(|tools| gemini_tools_are_mixed(&tools))
}
fn gemini_tools_are_mixed(tools: &[Value]) -> bool {
let has_function_declarations = tools.iter().any(|tool| {
tool.as_object().is_some_and(|tool| {
tool.get("functionDeclarations")
@@ -225,20 +256,7 @@ fn enable_server_side_tool_invocations_for_mixed_tools(output: &mut Value) -> Op
})
})
});
if !has_function_declarations || !has_builtin_tools {
return Some(());
}
let tool_config = output_object
.entry("toolConfig".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
tool_config.remove("include_server_side_tool_invocations");
tool_config.insert(
"includeServerSideToolInvocations".to_string(),
Value::Bool(true),
);
Some(())
has_function_declarations && has_builtin_tools
}
fn canonical_to_gemini_request_body(
@@ -1271,4 +1289,26 @@ mod tests {
serde_json::json!({"result": {"ok": true}})
);
}
#[test]
fn mixed_builtin_and_function_tools_require_gemini_three() {
let canonical = CanonicalRequest {
model: "gemini-2.5-pro".to_string(),
tools: vec![CanonicalToolDefinition {
name: "save_result".to_string(),
description: None,
parameters: Some(json!({"type": "object"})),
strict: None,
extensions: BTreeMap::new(),
}],
extensions: BTreeMap::from([(
"gemini".to_string(),
json!({"builtin_tools": [{"googleSearch": {}}]}),
)]),
..CanonicalRequest::default()
};
assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none());
assert!(to_raw(&canonical, "gemini-3-flash-preview", false).is_some());
}
}
@@ -184,6 +184,7 @@ fn validate_runtime_request_conversion(
request: &CanonicalRequest,
mapped_model: Option<&str>,
) -> Result<(), FormatError> {
validate_gemini_mixed_tool_model(source, target, request, mapped_model)?;
validate_openai_cross_format_store(source, target, body)?;
validate_openai_prompt_cache_contract(source, body, mapped_model)?;
validate_openai_reasoning_effort(source, target, body, mapped_model)?;
@@ -437,6 +438,7 @@ fn validate_request_conversion(
) -> Result<(), FormatError> {
let source = parse_format(source_format)?;
let target = parse_format(target_format)?;
validate_gemini_mixed_tool_model(source, target, request, mapped_model)?;
validate_openai_prompt_cache_contract(source, body, mapped_model)?;
validate_openai_reasoning_effort(source, target, body, mapped_model)?;
if source == target {
@@ -470,6 +472,34 @@ fn validate_request_conversion(
validate_cross_format_request_extensions(source, target, request)
}
fn validate_gemini_mixed_tool_model(
source: FormatId,
target: FormatId,
request: &CanonicalRequest,
mapped_model: Option<&str>,
) -> Result<(), FormatError> {
if source == target
|| target != FormatId::GeminiGenerateContent
|| !gemini_generate_content::request::canonical_has_mixed_gemini_tools(request)
{
return Ok(());
}
let target_model = mapped_model
.map(str::trim)
.filter(|model| !model.is_empty())
.unwrap_or(request.model.trim());
if crate::formats::shared::model_directives::gemini_model_supports_mixed_tools(target_model) {
return Ok(());
}
Err(FormatError::InvalidTargetField {
format: target.as_str().to_string(),
field: "tools".to_string(),
reason: format!(
"model {target_model:?} does not support combining built-in tools with custom function declarations; use a Gemini 3 model"
),
})
}
fn validate_openai_responses_cross_format_input(
source: FormatId,
target: FormatId,
@@ -3459,6 +3489,37 @@ mod tests {
.any(|field| field.field == "messages"));
}
#[test]
fn runtime_responses_to_gemini_rejects_mixed_tools_for_gemini_two() {
let body = json!({
"model": "gpt-5",
"input": "Search, then save the result.",
"tools": [
{"type": "web_search_preview"},
{
"type": "function",
"name": "save_result",
"parameters": {"type": "object"}
}
]
});
let context = FormatContext::default().with_mapped_model("gemini-2.5-pro");
let error = convert_request(
"openai:responses",
"gemini:generate_content",
&body,
&context,
)
.expect_err("Gemini 2.5 mixed tools should fail before reaching the provider");
assert!(matches!(
error,
FormatError::InvalidTargetField { ref field, ref reason, .. }
if field == "tools" && reason.contains("Gemini 3")
));
}
#[test]
fn pure_openai_chat_to_responses_preserves_explicit_tool_strict() {
let body = json!({
@@ -641,6 +641,10 @@ pub fn claude_model_uses_adaptive_effort(model: &str) -> bool {
}
pub fn gemini_model_uses_thinking_level(model: &str) -> bool {
gemini_model_supports_mixed_tools(model)
}
pub(crate) fn gemini_model_supports_mixed_tools(model: &str) -> bool {
model
.trim()
.to_ascii_lowercase()
@@ -2056,7 +2056,7 @@ mod tests {
let gemini = build_standard_request_body(
&request,
"openai:responses",
"gemini-2.5-pro",
"gemini-3-flash-preview",
"google",
"gemini:generate_content",
"/v1/responses",