From fdf55525f5af939293860c0691c79bbc07bc5af1 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:08:12 +0800 Subject: [PATCH] fix(ai-formats): keep client-declared search tools as Gemini function declarations canonical_tools_to_gemini promoted any tool whose name normalized to "websearch" / "googlesearch" / "websearchpreview" into Gemini's server-side builtin, dropping it from functionDeclarations. Claude Code declares an ordinary client-side `WebSearch` tool with a full input_schema, so every /v1/messages request routed to a Gemini model lost that declaration and gained `googleSearch` (rewritten to `googleSearchRetrieval` at the Anti Gravity transport boundary) instead. Two consequences, both observed on stabey-124 against gemini-3.8-flash: - the model can never emit a `WebSearch` tool_use, so the client's own web search is dead on that route; - when the model does reach for the injected server-side search, the v1internal backend answers `finishReason: MALFORMED_FUNCTION_CALL` / "Function call is empty - no input to parse." and the turn fails. Promote a tool to a builtin only when it is a bare marker carrying no schema. A declared schema means the caller intends to execute the call itself, which matches CLIProxyAPI: it keys builtins off Claude's `type: web_search_*` or an explicit `google_search` tool key and never off a function name. Co-Authored-By: Claude Opus 5 --- .../gemini/generate_content/request.rs | 73 ++++++++++++++++++- .../src/formats/shared/standard_matrix.rs | 60 +++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) 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 cfda9c244..7b95dcc1f 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 @@ -717,7 +717,7 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option { let mut url_context = false; for tool in &canonical.tools { - match normalize_gemini_builtin_tool_name(&tool.name) { + match canonical_tool_builtin_gemini_name(tool) { Some("googleSearch") => { google_search = true; continue; @@ -980,6 +980,25 @@ fn compact_gemini_contents(contents: Vec) -> Vec { compact } +/// Promote a canonical tool to a Gemini builtin only when it is a bare marker. +/// +/// Clients declare ordinary function tools whose names collide with the builtin +/// spellings — Claude Code ships a client-side `WebSearch` tool with a full +/// `input_schema`. Matching on the name alone dropped those declarations and +/// replaced them with server-side grounding, so the model could never call the +/// tool the client actually implements. A declared schema means the caller +/// expects to execute the call itself, so such tools stay function declarations. +fn canonical_tool_builtin_gemini_name(tool: &CanonicalToolDefinition) -> Option<&'static str> { + if tool + .parameters + .as_ref() + .is_some_and(|parameters| !parameters.is_null()) + { + return None; + } + normalize_gemini_builtin_tool_name(&tool.name) +} + fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> { match name .trim() @@ -1323,4 +1342,56 @@ mod tests { assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none()); assert!(to_raw(&canonical, "gemini-3-flash-preview", false).is_some()); } + + #[test] + fn client_declared_web_search_tool_stays_a_function_declaration() { + let canonical = CanonicalRequest { + model: "gemini-3-flash-preview".to_string(), + tools: vec![CanonicalToolDefinition { + name: "WebSearch".to_string(), + description: Some("Search the web".to_string()), + parameters: Some(json!({ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + })), + strict: None, + extensions: BTreeMap::new(), + }], + ..CanonicalRequest::default() + }; + + let tools = canonical_tools_to_gemini(&canonical).expect("tools should be emitted"); + let tools = tools.as_array().expect("tools should be an array"); + + assert!( + tools.iter().all(|tool| tool.get("googleSearch").is_none()), + "a client tool named WebSearch must not become server-side grounding: {tools:?}" + ); + assert_eq!( + tools[0]["functionDeclarations"][0]["name"], "WebSearch", + "the client declaration must survive: {tools:?}" + ); + } + + #[test] + fn schemaless_builtin_tool_name_still_maps_to_google_search() { + let canonical = CanonicalRequest { + model: "gemini-3-flash-preview".to_string(), + tools: vec![CanonicalToolDefinition { + name: "google_search".to_string(), + description: None, + parameters: None, + strict: None, + extensions: BTreeMap::new(), + }], + ..CanonicalRequest::default() + }; + + let tools = canonical_tools_to_gemini(&canonical).expect("tools should be emitted"); + let tools = tools.as_array().expect("tools should be an array"); + + assert_eq!(tools.len(), 1, "{tools:?}"); + assert_eq!(tools[0]["googleSearch"], json!({})); + } } 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 c85760d3b..3a862f407 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -1951,6 +1951,66 @@ mod tests { assert_eq!(converted["tools"][0]["googleSearch"], json!({})); } + #[test] + fn claude_client_web_search_tool_survives_conversion_to_gemini() { + let request = json!({ + "model": "gemini-3-flash-preview", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "find the release notes"}], + "tools": [ + { + "name": "WebSearch", + "description": "Search the web and use the results to inform responses", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } + }, + { + "name": "Read", + "description": "Read a file", + "input_schema": { + "type": "object", + "properties": {"file_path": {"type": "string"}}, + "required": ["file_path"] + } + } + ] + }); + + let converted = build_standard_request_body( + &request, + "claude:messages", + "gemini-3-flash-preview", + "google", + "gemini:generate_content", + "/v1/messages", + false, + None, + None, + ) + .expect("claude messages should convert to gemini"); + + let tools = converted["tools"] + .as_array() + .expect("tools should be an array"); + assert!( + tools.iter().all(|tool| tool.get("googleSearch").is_none() + && tool.get("googleSearchRetrieval").is_none()), + "a client-declared WebSearch tool must not become server-side grounding: {tools:?}" + ); + + let declared: Vec<&str> = tools + .iter() + .filter_map(|tool| tool.get("functionDeclarations")) + .filter_map(Value::as_array) + .flatten() + .filter_map(|declaration| declaration.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!(declared, vec!["WebSearch", "Read"], "{tools:?}"); + } + #[test] fn builds_claude_request_from_openai_chat_with_thinking_and_data_url_image() { let request = json!({