diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs index b3890dae0..1f1ca05f0 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs @@ -102,6 +102,59 @@ fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() { assert_eq!(provider_request_body["messages"][0]["content"], "hello"); } +#[test] +fn maps_openai_responses_additional_tools_without_message_name() { + let body_json = json!({ + "model": "gpt-5", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get the weather", + "parameters": { + "type": "object", + "properties": {} + } + }] + }, + { + "role": "user", + "content": "What is the weather?" + } + ] + }); + + let provider_request_body = build_cross_format_openai_responses_request_body( + &body_json, + "gpt-5-upstream", + "openai:responses", + "openai:chat", + false, + false, + "openai", + None, + None, + &http::HeaderMap::new(), + false, + ) + .expect("Responses additional tools should map to a Chat request body"); + + assert_eq!( + provider_request_body["messages"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!(provider_request_body["messages"][0]["role"], "user"); + assert!(provider_request_body["messages"][0].get("name").is_none()); + assert_eq!(provider_request_body["tools"][0]["type"], "function"); + assert_eq!( + provider_request_body["tools"][0]["function"]["name"], + "get_weather" + ); +} + #[test] fn local_openai_responses_wrapper_preserves_body_order_after_edits() { let body_json: Value = serde_json::from_str( diff --git a/apps/aether-gateway/src/execution_runtime/transport.rs b/apps/aether-gateway/src/execution_runtime/transport.rs index 8df7018d7..2eeb8c38d 100644 --- a/apps/aether-gateway/src/execution_runtime/transport.rs +++ b/apps/aether-gateway/src/execution_runtime/transport.rs @@ -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::(&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() 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 fa88caff9..83ebc8ee6 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 @@ -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()); + } } diff --git a/crates/aether-ai/formats/src/formats/registry.rs b/crates/aether-ai/formats/src/formats/registry.rs index 44b74d052..24dd9cebd 100644 --- a/crates/aether-ai/formats/src/formats/registry.rs +++ b/crates/aether-ai/formats/src/formats/registry.rs @@ -120,12 +120,15 @@ pub fn convert_request_pure_with_context( ctx: &FormatContext, ) -> Result, FormatError> { let pure_ctx = ctx.without_runtime_request_edits(); - let request = parse_request(source_format, body, &pure_ctx)?; - validate_openai_responses_target_contract(target_format, body)?; + let source = parse_format(source_format)?; + let target = parse_format(target_format)?; + let normalized_body = normalize_openai_responses_to_chat_body(source, target, body)?; + let request = parse_request(source_format, &normalized_body, &pure_ctx)?; + validate_openai_responses_target_contract(target_format, &normalized_body)?; validate_request_conversion( source_format, target_format, - body, + &normalized_body, &request, ctx.mapped_model.as_deref(), )?; @@ -158,12 +161,13 @@ pub fn convert_request( None }; let body = expanded_body.as_ref().unwrap_or(body); - validate_openai_responses_target_contract(target_format, body)?; - let mut request = parse_request(source_format, body, ctx)?; + let normalized_body = normalize_openai_responses_to_chat_body(source, target, body)?; + validate_openai_responses_target_contract(target_format, &normalized_body)?; + let mut request = parse_request(source_format, &normalized_body, ctx)?; validate_runtime_request_conversion( source, target, - body, + &normalized_body, &request, ctx.mapped_model.as_deref(), )?; @@ -177,6 +181,81 @@ pub fn convert_request( emit_request_inner(target_format, &request, ctx) } +fn normalize_openai_responses_to_chat_body( + source: FormatId, + target: FormatId, + body: &Value, +) -> Result { + if !matches!( + source, + FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact + ) || target != FormatId::OpenAiChat + { + return Ok(body.clone()); + } + + let Some(body_object) = body.as_object() else { + return Ok(body.clone()); + }; + let Some(input) = body_object.get("input").and_then(Value::as_array) else { + return Ok(body.clone()); + }; + let additional_tools_count = input + .iter() + .take_while(|item| is_openai_responses_additional_tools_item(item)) + .count(); + if additional_tools_count == 0 { + return Ok(body.clone()); + } + if body_object + .get("tools") + .is_some_and(|tools| !tools.is_array()) + { + return Ok(body.clone()); + } + + let mut normalized = body.clone(); + let normalized_object = normalized + .as_object_mut() + .expect("Responses request body object was checked above"); + let normalized_input = normalized_object + .get_mut("input") + .and_then(Value::as_array_mut) + .expect("Responses request input array was checked above"); + let additional_tools = normalized_input.drain(..additional_tools_count); + + let mut tools = Vec::new(); + for additional_tools in additional_tools { + tools.extend( + additional_tools["tools"] + .as_array() + .expect("additional_tools item was checked above") + .iter() + .cloned(), + ); + } + if let Some(existing_tools) = normalized_object.get("tools").and_then(Value::as_array) { + tools.extend(existing_tools.iter().cloned()); + } + normalized_object.insert("tools".to_string(), Value::Array(tools)); + Ok(normalized) +} + +fn is_openai_responses_additional_tools_item(value: &Value) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + object + .get("type") + .and_then(Value::as_str) + .is_some_and(|item_type| item_type.eq_ignore_ascii_case("additional_tools")) + && object.get("role").and_then(Value::as_str) == Some("developer") + && object.get("tools").is_some_and(Value::is_array) + && object + .keys() + .all(|key| matches!(key.as_str(), "type" | "role" | "tools")) +} + fn validate_runtime_request_conversion( source: FormatId, target: FormatId, @@ -184,6 +263,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 +517,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 +551,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, @@ -1498,8 +1607,11 @@ fn validate_request_extension_namespace( }); }; for key in object.keys() { - if request_extension_key_is_cross_format_safe(source, target, location, namespace, key) - { + if openai_responses_custom_tool_key_is_cross_format_safe( + source, target, location, namespace, object, key, + ) || request_extension_key_is_cross_format_safe( + source, target, location, namespace, key, + ) { continue; } return Err(FormatError::LossyConversionBlocked { @@ -1514,6 +1626,29 @@ fn validate_request_extension_namespace( Ok(()) } +fn openai_responses_custom_tool_key_is_cross_format_safe( + source: FormatId, + target: FormatId, + location: &str, + namespace: &str, + extension: &Map, + key: &str, +) -> bool { + matches!( + (source, target, location, namespace), + ( + FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact, + FormatId::OpenAiChat, + "tools[]", + "openai_responses" | "openai_cli" + ) + ) && extension + .get("type") + .and_then(Value::as_str) + .is_some_and(|tool_type| tool_type.eq_ignore_ascii_case("custom")) + && matches!(key, "type" | "name" | "description" | "format" | "custom") +} + fn request_extension_key_is_cross_format_safe( source: FormatId, target: FormatId, @@ -2579,7 +2714,7 @@ fn validate_openai_responses_to_chat( .unwrap_or("function") .trim() .to_ascii_lowercase(); - if !matches!(tool_type.as_str(), "function" | "namespace") { + if !matches!(tool_type.as_str(), "function" | "custom" | "namespace") { return Err(FormatError::LossyConversionBlocked { source_format: FormatId::OpenAiResponses.as_str().to_string(), target_format: FormatId::OpenAiChat.as_str().to_string(), @@ -3459,6 +3594,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!({ @@ -5114,6 +5280,130 @@ mod tests { )); } + #[test] + fn openai_responses_additional_tools_prefix_maps_to_chat_tools() { + let body = json!({ + "model": "gpt-5.6-sol", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{ + "type": "function", + "name": "lookup", + "description": "Look up a value", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + }, + "strict": true + }, { + "type": "custom", + "name": "shell_command", + "description": "Run a shell command", + "format": {"type": "text"} + }] + }, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]} + ], + "tools": [{ + "type": "function", + "name": "existing", + "parameters": {"type": "object"} + }] + }); + + for converted in [ + convert_request_pure("openai:responses", "openai:chat", &body) + .expect("pure conversion should map additional tools") + .value, + convert_request( + "openai:responses", + "openai:chat", + &body, + &FormatContext::default(), + ) + .expect("runtime conversion should map additional tools"), + ] { + assert_eq!(converted["tools"][0]["type"], "function"); + assert_eq!(converted["tools"][0]["function"]["name"], "lookup"); + assert_eq!(converted["tools"][0]["function"]["strict"], true); + assert_eq!(converted["tools"][1]["type"], "custom"); + assert_eq!(converted["tools"][1]["custom"]["name"], "shell_command"); + assert_eq!(converted["tools"][2]["function"]["name"], "existing"); + assert_eq!(converted["messages"][0]["role"], "user"); + assert_eq!(converted["messages"].as_array().map(Vec::len), Some(1)); + } + } + + #[test] + fn openai_responses_additional_tools_prefix_rejects_unmapped_tool() { + let body = json!({ + "model": "gpt-5.6-sol", + "input": [{ + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "tool_search", "execution": "client"}] + }] + }); + + let error = convert_request_pure("openai:responses", "openai:chat", &body) + .expect_err("Chat cannot represent client tool_search"); + + assert!(matches!( + error, + super::FormatError::LossyConversionBlocked { ref field, .. } + if field == "tools" + )); + } + + #[test] + fn openai_responses_additional_tools_prefix_rejects_unknown_fields() { + let body = json!({ + "model": "gpt-5.6-sol", + "input": [{ + "type": "additional_tools", + "role": "developer", + "tools": [], + "future_field": true + }] + }); + + let error = convert_request_pure("openai:responses", "openai:chat", &body) + .expect_err("unknown additional_tools fields must not be dropped"); + + assert!(matches!( + error, + super::FormatError::LossyConversionBlocked { ref field, .. } + if field == "input[0]" + )); + } + + #[test] + fn openai_responses_additional_tools_is_only_consumed_as_a_leading_prefix() { + let body = json!({ + "model": "gpt-5.6-sol", + "input": [ + {"type": "message", "role": "user", "content": "hello"}, + { + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "function", "name": "lookup", "parameters": {}}] + } + ] + }); + + let error = convert_request_pure("openai:responses", "openai:chat", &body) + .expect_err("additional_tools after conversation history must remain unsupported"); + + assert!(matches!( + error, + super::FormatError::LossyConversionBlocked { ref field, .. } + if field == "input[1]" + )); + } + #[test] fn runtime_openai_responses_cross_format_rejects_unknown_content_block() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/shared/model_directives.rs b/crates/aether-ai/formats/src/formats/shared/model_directives.rs index c71782c18..499cf7ae7 100644 --- a/crates/aether-ai/formats/src/formats/shared/model_directives.rs +++ b/crates/aether-ai/formats/src/formats/shared/model_directives.rs @@ -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() 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 6216498ec..e46b2d808 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -1783,7 +1783,7 @@ mod tests { let converted = build_standard_request_body( &request, "claude:messages", - "gemini-2.5-pro", + "gemini-3-flash-preview", "google", "gemini:generate_content", "/v1/messages", @@ -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", diff --git a/crates/aether-provider/transport/src/antigravity/request.rs b/crates/aether-provider/transport/src/antigravity/request.rs index 0dd136cc5..8142f21f7 100644 --- a/crates/aether-provider/transport/src/antigravity/request.rs +++ b/crates/aether-provider/transport/src/antigravity/request.rs @@ -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"