diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index 87d65f44f..f5d18246b 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -2198,10 +2198,6 @@ struct OpenAIResponsesClientToolResultState { item_started: bool, } -fn is_responses_web_search_tool(name: &str) -> bool { - matches!(name, "web_search" | "web_search_preview") -} - fn web_search_query_from_arguments(arguments: &str) -> String { serde_json::from_str::(arguments) .ok() @@ -3526,12 +3522,14 @@ impl OpenAIResponsesClientEmitter { .map(|(_, child_name)| child_name.to_string()) .unwrap_or_else(|| name.clone()); let emitted_namespace = namespaced_tool.map(|(namespace, _)| namespace.to_string()); - let is_namespaced_tool = namespaced_tool.is_some(); + let web_search = self + .namespace_tool_aliases + .emits_hosted_web_search_call(&name); let state = self.tool_calls.entry(index).or_default(); state.call_id = call_id.clone(); state.name = emitted_name; state.namespace = emitted_namespace; - state.web_search = !is_namespaced_tool && is_responses_web_search_tool(&name); + state.web_search = web_search; let emitted_call_id = state.call_id.clone(); let emitted_name = state.name.clone(); let emitted_namespace = state.namespace.clone(); @@ -6379,6 +6377,53 @@ mod tests { assert!(!sse.contains("response.function_call_arguments.delta")); } + #[test] + fn openai_responses_client_emitter_keeps_client_declared_web_search_function_as_function_call() + { + let mut emitter = OpenAIResponsesClientEmitter::with_report_context(&json!({ + "original_request_body": { + "tools": [{ + "type": "function", + "name": "web_search", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}} + }] + } + })); + let mut bytes = Vec::new(); + for event in [ + CanonicalStreamEvent::ToolCallStart { + index: 0, + call_id: "call_ws_1".to_string(), + name: "web_search".to_string(), + }, + CanonicalStreamEvent::ToolCallArgumentsDelta { + index: 0, + arguments: r#"{"query":"today tech"}"#.to_string(), + }, + CanonicalStreamEvent::Finish { + finish_reason: Some("tool_calls".to_string()), + usage: None, + }, + ] { + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_123".to_string(), + model: "gemini-3.8-flash".to_string(), + event, + }) + .expect("event should encode"), + ); + } + + let sse = String::from_utf8(bytes).expect("sse should be utf8"); + assert!(!sse.contains("web_search_call")); + assert!(sse.contains(r#""type":"function_call""#)); + assert!(sse.contains(r#""call_id":"call_ws_1""#)); + assert!(sse.contains(r#""name":"web_search""#)); + assert!(sse.contains("response.function_call_arguments.delta")); + } + #[test] fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() { let mut state = OpenAIResponsesProviderState::default(); diff --git a/crates/aether-ai/formats/src/formats/openai/namespace.rs b/crates/aether-ai/formats/src/formats/openai/namespace.rs index fa25c532a..9be878126 100644 --- a/crates/aether-ai/formats/src/formats/openai/namespace.rs +++ b/crates/aether-ai/formats/src/formats/openai/namespace.rs @@ -54,6 +54,7 @@ pub(crate) struct NamespaceToolAliases { by_chat_name: BTreeMap, namespace_tool_indices: BTreeSet, invalid_namespace_tool_indices: BTreeSet, + client_function_tool_names: BTreeSet, } impl NamespaceToolAliases { @@ -171,10 +172,24 @@ impl NamespaceToolAliases { else { return Self::default(); }; - let Some(canonical) = openai_responses_tools_to_canonical(Some(tools)) else { - return Self::default(); - }; - Self::from_canonical_tools(&canonical) + let client_function_tool_names = client_function_tool_names(tools); + let mut result = openai_responses_tools_to_canonical(Some(tools)) + .map(|canonical| Self::from_canonical_tools(&canonical)) + .unwrap_or_default(); + result.client_function_tool_names = client_function_tool_names; + result + } + + /// Whether a tool call named `name` should surface to a Responses client as + /// a hosted `web_search_call`. A client that declared its own function or + /// custom tool called `web_search` must get a `function_call` back, or it + /// cannot answer the call and will echo an unconvertible hosted item. + /// When the client declares both a hosted `web_search` tool and a function + /// of the same name, the function wins: only the client can answer it. + pub(crate) fn emits_hosted_web_search_call(&self, name: &str) -> bool { + matches!(name, "web_search" | "web_search_preview") + && self.responses_name(name).is_none() + && !self.client_function_tool_names.contains(name) } pub(crate) fn chat_name(&self, namespace: &str, child_name: &str) -> Option<&str> { @@ -227,6 +242,32 @@ impl NamespaceToolAliases { } } +fn client_function_tool_names(tools: &Value) -> BTreeSet { + tools + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_object) + .filter(|tool| { + tool.get("type") + .and_then(Value::as_str) + .map(str::trim) + .is_none_or(|tool_type| { + tool_type.eq_ignore_ascii_case("function") + || tool_type.eq_ignore_ascii_case("custom") + }) + }) + .filter_map(|tool| { + non_empty_string(tool.get("name")).or_else(|| { + ["function", "custom"] + .iter() + .find_map(|key| non_empty_string(tool.get(*key)?.get("name"))) + }) + }) + .map(ToOwned::to_owned) + .collect() +} + pub(crate) fn canonical_tool_is_responses_namespace(tool: &CanonicalToolDefinition) -> bool { raw_responses_tool(tool).is_some_and(|raw| { raw.get("type") @@ -414,6 +455,111 @@ mod tests { ); } + fn hosted_web_search(tools: Value, name: &str) -> bool { + NamespaceToolAliases::from_report_context(&json!({ + "original_request_body": {"tools": tools} + })) + .emits_hosted_web_search_call(name) + } + + #[test] + fn hosted_web_search_call_is_reserved_for_undeclared_search_names() { + let schema = json!({"type": "object", "properties": {"query": {"type": "string"}}}); + let cases = [ + ("no tools", json!([]), "web_search", true), + ( + "hosted tool", + json!([{"type": "web_search"}]), + "web_search", + true, + ), + ( + "hosted preview tool", + json!([{"type": "web_search_preview"}]), + "web_search_preview", + true, + ), + ( + "function tool", + json!([{"type": "function", "name": "web_search", "parameters": schema}]), + "web_search", + false, + ), + ( + "function tool named preview", + json!([{"type": "function", "name": "web_search_preview", "parameters": schema}]), + "web_search_preview", + false, + ), + ( + "custom tool", + json!([{"type": "custom", "name": "web_search"}]), + "web_search", + false, + ), + ( + "tool without type", + json!([{"name": "web_search", "parameters": schema}]), + "web_search", + false, + ), + ( + "chat-shaped function tool", + json!([{"type": "function", "function": {"name": "web_search", "parameters": schema}}]), + "web_search", + false, + ), + ( + "chat-shaped custom tool", + json!([{"type": "custom", "custom": {"name": "web_search"}}]), + "web_search", + false, + ), + ( + "hosted and function tool together", + json!([ + {"type": "web_search"}, + {"type": "function", "name": "web_search", "parameters": schema} + ]), + "web_search", + false, + ), + ( + "unrelated function tool", + json!([{"type": "function", "name": "lookup", "parameters": schema}]), + "web_search", + true, + ), + ( + "non-search name", + json!([{"type": "web_search"}]), + "lookup", + false, + ), + ]; + + for (label, tools, name, expected) in cases { + assert_eq!(hosted_web_search(tools, name), expected, "{label}"); + } + } + + #[test] + fn namespaced_web_search_child_is_not_a_hosted_web_search_call() { + assert!(!hosted_web_search( + json!([{ + "type": "namespace", + "name": "mcp__search", + "description": "Search tools", + "tools": [{ + "type": "function", + "name": "web_search", + "parameters": {"type": "object", "properties": {}} + }] + }]), + "web_search" + )); + } + #[test] fn namespace_aliases_are_unique_bounded_and_prefix_safe() { let long_namespace = format!("namespace__{}", "n".repeat(120)); diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index 86d44dc4b..49b2b9d14 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -275,7 +275,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo })); } let namespaced_tool = namespace_tool_aliases.responses_name(name); - if namespaced_tool.is_none() && is_responses_web_search_tool(name) { + if namespace_tool_aliases.emits_hosted_web_search_call(name) { output.push(json!({ "type": "web_search_call", "id": id, @@ -601,10 +601,6 @@ fn openai_responses_output_format_from_mime_type(mime_type: &str) -> String { .to_string() } -fn is_responses_web_search_tool(name: &str) -> bool { - matches!(name, "web_search" | "web_search_preview") -} - fn web_search_query_from_value(input: &Value) -> String { input .get("query") @@ -647,6 +643,70 @@ mod tests { assert!(body["completed_at"].as_i64().is_some()); } + #[test] + fn responses_response_builder_keeps_client_declared_web_search_function_as_function_call() { + let report_context = json!({ + "original_request_body": { + "tools": [{ + "type": "function", + "name": "web_search", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}} + }] + } + }); + let response = CanonicalResponse { + id: "resp_test".to_string(), + model: "gemini-3.8-flash".to_string(), + content: vec![CanonicalContentBlock::ToolUse { + id: "call_ws_1".to_string(), + name: "web_search".to_string(), + input: json!({"query": "today tech"}), + extensions: BTreeMap::new(), + }], + outputs: Vec::new(), + stop_reason: Some(CanonicalStopReason::ToolUse), + usage: None, + extensions: BTreeMap::new(), + }; + + let body = to_raw(&response, &report_context, false); + + let item = &body["output"][0]; + assert_eq!(item["type"], "function_call"); + assert_eq!(item["name"], "web_search"); + assert_eq!(item["call_id"], "call_ws_1"); + assert_eq!( + serde_json::from_str::(item["arguments"].as_str().unwrap()).unwrap(), + json!({"query": "today tech"}) + ); + } + + #[test] + fn responses_response_builder_emits_web_search_call_for_hosted_web_search_tool() { + let report_context = json!({ + "original_request_body": {"tools": [{"type": "web_search"}]} + }); + let response = CanonicalResponse { + id: "resp_test".to_string(), + model: "gpt-5-5-low".to_string(), + content: vec![CanonicalContentBlock::ToolUse { + id: "call_ws_1".to_string(), + name: "web_search".to_string(), + input: json!({"query": "today tech"}), + extensions: BTreeMap::new(), + }], + outputs: Vec::new(), + stop_reason: Some(CanonicalStopReason::ToolUse), + usage: None, + extensions: BTreeMap::new(), + }; + + let body = to_raw(&response, &report_context, false); + + assert_eq!(body["output"][0]["type"], "web_search_call"); + assert_eq!(body["output"][0]["action"]["query"], "today tech"); + } + #[test] fn responses_response_builder_restores_namespaced_chat_tool_identity() { let report_context = json!({ diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index 4b05215a4..e6205d9d8 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -973,6 +973,66 @@ mod tests { ); } + #[test] + fn streams_gemini_web_search_function_call_to_responses_function_call_for_function_tool() { + let mut context = report_context("gemini:generate_content", "openai:responses"); + context["original_request_body"] = json!({ + "model": "gemini-3.8-flash", + "tools": [{ + "type": "function", + "name": "web_search", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}} + }] + }); + let mut matrix = StreamingStandardFormatMatrix::default(); + let mut output = matrix + .transform_line( + &context, + data_line(json!({ + "response": { + "responseId": "resp_ws_function", + "modelVersion": "gemini-3.8-flash", + "candidates": [{ + "index": 0, + "content": { + "role": "model", + "parts": [{ + "thoughtSignature": "signature", + "functionCall": { + "name": "web_search", + "args": {"query": "conpty newline"}, + "id": "call_109312" + } + }] + }, + "finishReason": "STOP" + }] + } + })), + ) + .expect("Gemini function call should transform"); + output.extend(matrix.finish(&context).expect("stream should finish")); + + let events = json_data_events(&output); + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .expect("response should complete"); + let items = completed["response"]["output"] + .as_array() + .expect("completed response should carry output"); + assert!( + items.iter().all(|item| item["type"] != "web_search_call"), + "{items:?}" + ); + let call = items + .iter() + .find(|item| item["type"] == "function_call") + .expect("function tool call should stay a function_call"); + assert_eq!(call["name"], "web_search"); + assert_eq!(call["call_id"], "call_109312"); + } + #[test] fn terminal_observer_marks_malformed_gemini_function_call_as_failure() { let context = report_context("gemini:generate_content", "openai:responses");