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 <[email protected]>
This commit is contained in:
stabey
2026-09-17 09:23:01 +08:00
co-authored by Claude Opus 5
parent 4ff4129034
commit fdf55525f5
2 changed files with 132 additions and 1 deletions
@@ -717,7 +717,7 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
let mut url_context = false; let mut url_context = false;
for tool in &canonical.tools { for tool in &canonical.tools {
match normalize_gemini_builtin_tool_name(&tool.name) { match canonical_tool_builtin_gemini_name(tool) {
Some("googleSearch") => { Some("googleSearch") => {
google_search = true; google_search = true;
continue; continue;
@@ -980,6 +980,25 @@ fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
compact 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> { fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> {
match name match name
.trim() .trim()
@@ -1323,4 +1342,56 @@ mod tests {
assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none()); assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none());
assert!(to_raw(&canonical, "gemini-3-flash-preview", false).is_some()); 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!({}));
}
} }
@@ -1951,6 +1951,66 @@ mod tests {
assert_eq!(converted["tools"][0]["googleSearch"], json!({})); 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] #[test]
fn builds_claude_request_from_openai_chat_with_thinking_and_data_url_image() { fn builds_claude_request_from_openai_chat_with_thinking_and_data_url_image() {
let request = json!({ let request = json!({