mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
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:
@@ -717,7 +717,7 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
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<Value>) -> Vec<Value> {
|
||||
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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user