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 83ebc8ee6..cfda9c244 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 @@ -218,6 +218,18 @@ fn enable_server_side_tool_invocations_for_mixed_tools( if !gemini_model_supports_mixed_tools(mapped_model) { return None; } + ensure_server_side_tool_invocations_for_mixed_tools(output) +} + +pub fn ensure_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(()); + }; + if !gemini_tools_are_mixed(tools) { + return Some(()); + } let tool_config = output_object .entry("toolConfig".to_string()) diff --git a/crates/aether-ai/formats/src/lib.rs b/crates/aether-ai/formats/src/lib.rs index fbb8a3f1e..5dcce8c0b 100644 --- a/crates/aether-ai/formats/src/lib.rs +++ b/crates/aether-ai/formats/src/lib.rs @@ -12,6 +12,7 @@ pub use formats::context::{ ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted, FormatContext, FormatError, }; +pub use formats::gemini::generate_content::request::ensure_server_side_tool_invocations_for_mixed_tools; pub use formats::id::{ api_format_alias_matches, api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream, api_format_permission_covers, diff --git a/crates/aether-provider/transport/src/same_format_provider/mod.rs b/crates/aether-provider/transport/src/same_format_provider/mod.rs index 828b87c2e..04b68587a 100644 --- a/crates/aether-provider/transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider/transport/src/same_format_provider/mod.rs @@ -413,6 +413,27 @@ fn build_same_format_provider_request_body_inner( "applied configured provider body rules", ); } + if matches!(input.family, SameFormatProviderFamily::Gemini) + && aether_ai_formats::api_format_alias_matches( + input.provider_api_format, + "gemini:generate_content", + ) + { + let before_mixed_tool_compatibility = compatibility_edits + .is_some() + .then(|| provider_request_body.clone()); + aether_ai_formats::ensure_server_side_tool_invocations_for_mixed_tools( + &mut provider_request_body, + )?; + if before_mixed_tool_compatibility.is_some_and(|before| before != provider_request_body) { + record_compatibility_edit( + &mut compatibility_edits, + "toolConfig.includeServerSideToolInvocations", + SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite, + "enabled Gemini server-side tool invocations for mixed built-in and function tools", + ); + } + } if matches!(input.family, SameFormatProviderFamily::Gemini) && aether_ai_formats::api_format_alias_matches( input.provider_api_format, @@ -2057,6 +2078,60 @@ mod tests { assert_eq!(function_response["name"], "lookup_snake"); } + #[test] + fn same_format_gemini_body_enables_mixed_tool_invocations() { + let output = build_same_format_provider_request_body_with_compatibility_report( + SameFormatProviderRequestBodyInput { + body_json: &json!({ + "contents": [{ + "role": "user", + "parts": [{"text": "Search, then save the result."}] + }], + "tools": [ + {"googleSearch": {}}, + { + "functionDeclarations": [{ + "name": "save_result", + "parameters": {"type": "object"} + }] + } + ], + "toolConfig": { + "functionCallingConfig": {"mode": "AUTO"} + } + }), + mapped_model: "gemini-3.7-flash", + client_api_format: "gemini:generate_content", + provider_api_format: "gemini:generate_content", + source_model: Some("gemini-3.7-flash"), + family: SameFormatProviderFamily::Gemini, + body_rules: None, + request_headers: None, + upstream_is_stream: true, + force_body_stream_field: false, + kiro_auth_config: None, + is_claude_code: false, + enable_model_directives: false, + }, + ) + .expect("same-format Gemini body should build"); + + assert_eq!(output.body["tools"][0]["googleSearch"], json!({})); + assert_eq!( + output.body["tools"][1]["functionDeclarations"][0]["name"], + "save_result" + ); + assert_eq!( + output.body["toolConfig"]["includeServerSideToolInvocations"], + true + ); + assert!(output.compatibility_edits.iter().any(|edit| { + edit.field == "toolConfig.includeServerSideToolInvocations" + && edit.action + == SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite + })); + } + #[test] fn same_format_body_report_records_provider_compatibility_edits() { let output = build_same_format_provider_request_body_with_compatibility_report(