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
@@ -5855,6 +5855,100 @@ mod tests {
);
}
#[tokio::test]
async fn direct_sync_execution_runtime_preserves_gemini_tool_config_on_wire() {
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let captured_body = Arc::new(Mutex::new(None));
let captured_body_for_handler = Arc::clone(&captured_body);
let app = Router::new().route(
"/generate",
post(move |body: Bytes| {
let captured_body = Arc::clone(&captured_body_for_handler);
async move {
*captured_body
.lock()
.expect("capture lock should not be poisoned") = Some(body.to_vec());
Json(json!({"ok": true}))
}
}),
);
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test server should run");
});
let result = DirectSyncExecutionRuntime::new()
.execute_sync(&ExecutionPlan {
request_id: "req-gemini-tool-config-wire".into(),
candidate_id: Some("cand-gemini-tool-config-wire".into()),
provider_name: Some("google".into()),
provider_id: "prov-gemini-tool-config-wire".into(),
endpoint_id: "ep-gemini-tool-config-wire".into(),
key_id: "key-gemini-tool-config-wire".into(),
method: "POST".into(),
url: format!("http://{addr}/generate"),
headers: BTreeMap::from([("content-type".into(), "application/json".into())]),
content_type: Some("application/json".into()),
content_encoding: None,
body: RequestBody::from_json(json!({
"model": "gemini-3-flash-preview",
"contents": [{
"role": "user",
"parts": [{"text": "Search, then save the result."}]
}],
"tools": [
{"googleSearch": {}},
{"functionDeclarations": [{
"name": "save_result",
"parameters": {
"type": "OBJECT",
"properties": {"result": {"type": "STRING"}}
}
}]}
],
"toolConfig": {
"includeServerSideToolInvocations": true,
"functionCallingConfig": {"mode": "ANY"}
}
})),
stream: false,
client_api_format: "openai:responses".into(),
provider_api_format: "gemini:generate_content".into(),
model_name: Some("gemini-3-flash-preview".into()),
proxy: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(LOCAL_HTTP_SUCCESS_TIMEOUT_MS),
..ExecutionTimeouts::default()
}),
})
.await
.expect("sync execution should succeed");
server.abort();
assert_eq!(result.status_code, 200);
let body = captured_body
.lock()
.expect("capture lock should not be poisoned")
.take()
.and_then(|body| serde_json::from_slice::<serde_json::Value>(&body).ok())
.expect("upstream should receive a JSON body");
assert_eq!(
body["toolConfig"]["includeServerSideToolInvocations"],
json!(true)
);
assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
assert!(body["toolConfig"]
.get("include_server_side_tool_invocations")
.is_none());
}
#[tokio::test]
async fn direct_sync_execution_runtime_applies_non_stream_total_timeout_to_body() {
let listener = crate::test_support::bind_loopback_listener()
@@ -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",
@@ -177,6 +177,7 @@ mod tests {
}
},
"toolConfig": {
"includeServerSideToolInvocations": true,
"functionCallingConfig": {
"mode": "VALIDATED"
}
@@ -245,6 +246,13 @@ mod tests {
envelope["request"]["toolConfig"]["functionCallingConfig"]["mode"],
"VALIDATED"
);
assert_eq!(
envelope["request"]["toolConfig"]["includeServerSideToolInvocations"],
true
);
assert!(envelope["request"]["toolConfig"]
.get("include_server_side_tool_invocations")
.is_none());
assert_eq!(
envelope["request"]["tools"][0]["functionDeclarations"][0]["name"],
"run_command"