diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs index 262bb41da..fa88caff9 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs @@ -187,9 +187,60 @@ pub fn to_raw( ) -> Option { 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)?; + } Some(output) } +fn canonical_has_raw_gemini_tools(canonical: &CanonicalRequest) -> bool { + canonical + .extensions + .get("gemini") + .and_then(Value::as_object) + .is_some_and(|gemini| gemini.contains_key("raw_tools")) +} + +fn enable_server_side_tool_invocations_for_mixed_tools(output: &mut Value) -> 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(()); + }; + let has_function_declarations = tools.iter().any(|tool| { + tool.as_object().is_some_and(|tool| { + tool.get("functionDeclarations") + .or_else(|| tool.get("function_declarations")) + .and_then(Value::as_array) + .is_some_and(|declarations| !declarations.is_empty()) + }) + }); + let has_builtin_tools = tools.iter().any(|tool| { + tool.as_object().is_some_and(|tool| { + tool.keys().any(|key| { + !matches!( + key.as_str(), + "functionDeclarations" | "function_declarations" + ) + }) + }) + }); + 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(()) +} + fn canonical_to_gemini_request_body( canonical: &CanonicalRequest, mapped_model: &str, diff --git a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs index bd1dca6f2..6216498ec 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -2029,4 +2029,52 @@ mod tests { "surface conversion should preserve the Claude tool schema before transport envelopes" ); } + + #[test] + fn openai_responses_builtin_and_function_tools_enable_gemini_server_invocations() { + let request = json!({ + "model": "gpt-5", + "input": "Search first, then save the result.", + "tools": [ + {"type": "web_search_preview"}, + { + "type": "function", + "name": "save_result", + "description": "Save a search result", + "parameters": { + "type": "object", + "properties": { + "result": {"type": "string"} + }, + "required": ["result"] + } + } + ], + "tool_choice": "required" + }); + + let gemini = build_standard_request_body( + &request, + "openai:responses", + "gemini-2.5-pro", + "google", + "gemini:generate_content", + "/v1/responses", + true, + None, + None, + ) + .expect("openai responses should convert to gemini generate content"); + + assert_eq!(gemini["tools"][0]["googleSearch"], json!({})); + assert_eq!( + gemini["tools"][1]["functionDeclarations"][0]["name"], + "save_result" + ); + assert_eq!( + gemini["toolConfig"]["includeServerSideToolInvocations"], + true + ); + assert_eq!(gemini["toolConfig"]["functionCallingConfig"]["mode"], "ANY"); + } }