From 8ea4370a039c9a9bb3b7159da638d0e9773ae13b Mon Sep 17 00:00:00 2001 From: Simo Lin Date: Tue, 21 Apr 2026 18:22:27 -0700 Subject: [PATCH 1/2] feat(protocols): implement T3 non-preview web_search tool and results field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: add `ResponseTool::WebSearch(WebSearchTool)` variant with typed `filters { allowed_domains }`, `search_context_size` enum (low|medium| high), and `user_location { city, country, region, timezone, type }` sub-shapes. Wire the `results: Option>` field on `ResponseOutputItem::WebSearchCall` so callers requesting `web_search_call.results` via the top-level `include[]` array receive a typed array. Accept the versioned alias `web_search_2025_08_26` on deserialization. Why: OpenAI Responses spec §tools line 439 defines `web_search` as a distinct tool from the existing `web_search_preview` — non-preview adds `filters.allowed_domains` and constrains `search_context_size` to a typed enum. P4 (merged in #1274) already landed the matching `IncludeField` variants for `web_search_call.results` and `web_search_call.action.sources`; T3 now wires the actual output struct so the wire round-trip is complete. How: new tagged variant on `ResponseTool` using `#[serde(rename = "web_search", alias = "web_search_2025_08_26")]` so canonical serialization emits `"web_search"` while still accepting the dated tag. The `WebSearchCall` output struct gains `results` gated by `#[serde(default, skip_serializing_if = "Option::is_none")]` — absent results serialize byte-identically to `{id, action, status, type}` per spec, populated results ride alongside. Mirrors the `FileSearchResult` shape precedent for the `results` entry fields. Construction sites in the MCP response transformer and exhaustive matches in the model_gateway harmony builder and OpenAI responses utils are updated to compile. Two serde round-trip tests pin both the tool declaration and the output struct (populated + absent cases). Refs: T3 Signed-off-by: Simo Lin --- crates/mcp/src/transform/transformer.rs | 11 +- crates/protocols/src/responses.rs | 179 ++++++++++++++++++ .../src/routers/grpc/harmony/builder.rs | 1 + .../src/routers/openai/responses/utils.rs | 6 +- 4 files changed, 194 insertions(+), 3 deletions(-) diff --git a/crates/mcp/src/transform/transformer.rs b/crates/mcp/src/transform/transformer.rs index d8ffe8d98c..0cc2121fb2 100644 --- a/crates/mcp/src/transform/transformer.rs +++ b/crates/mcp/src/transform/transformer.rs @@ -179,6 +179,9 @@ impl ResponseTransformer { queries, sources, }, + // Populated only when the caller asks for `web_search_call.results` + // via include[]; transformer leaves it unset so default omits the field. + results: None, } } @@ -692,9 +695,15 @@ mod tests { ); match transformed { - ResponseOutputItem::WebSearchCall { id, status, action } => { + ResponseOutputItem::WebSearchCall { + id, + status, + action, + results, + } => { assert_eq!(id, "ws_req-123"); assert_eq!(status, WebSearchCallStatus::Completed); + assert!(results.is_none()); match action { WebSearchAction::Search { query, diff --git a/crates/protocols/src/responses.rs b/crates/protocols/src/responses.rs index 20018e6dee..5ef9414813 100644 --- a/crates/protocols/src/responses.rs +++ b/crates/protocols/src/responses.rs @@ -349,6 +349,15 @@ pub enum ResponseTool { #[serde(rename = "web_search_preview")] WebSearchPreview(WebSearchPreviewTool), + /// Built-in non-preview hosted web search tool. + /// + /// Spec: `{ type: "web_search" | "web_search_2025_08_26", filters? { allowed_domains? }, + /// search_context_size?: "low"|"medium"|"high", user_location? }`. Distinct from + /// `web_search_preview` — non-preview adds `filters.allowed_domains` and constrains + /// `search_context_size` to a typed enum. + #[serde(rename = "web_search", alias = "web_search_2025_08_26")] + WebSearch(WebSearchTool), + /// Built-in tool. #[serde(rename = "code_interpreter")] CodeInterpreter(CodeInterpreterTool), @@ -484,6 +493,67 @@ pub struct WebSearchPreviewTool { pub user_location: Option, } +/// Non-preview hosted web search tool configuration. +/// +/// Spec: `{ type: "web_search" | "web_search_2025_08_26", filters? { allowed_domains? }, +/// search_context_size?: "low"|"medium"|"high", user_location? }`. +/// +/// Distinct from `WebSearchPreviewTool`: adds `filters.allowed_domains` (domain +/// allowlist) and pins `search_context_size` to the spec-listed enum. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WebSearchTool { + /// Optional domain allowlist applied to candidate sources. + pub filters: Option, + /// Search context budget. Spec enum: `"low" | "medium" | "high"`. + pub search_context_size: Option, + /// Approximate user location used to bias results. + pub user_location: Option, +} + +/// Filters for the non-preview `web_search` tool. +/// +/// Spec: `filters? { allowed_domains? }`. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WebSearchFilters { + /// Optional list of domains to restrict search results to. + pub allowed_domains: Option>, +} + +/// Search context budget for the non-preview `web_search` tool. +/// +/// Spec: `search_context_size?: "low" | "medium" | "high"`. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WebSearchContextSize { + Low, + Medium, + High, +} + +/// Approximate user location for the non-preview `web_search` tool. +/// +/// Spec: `user_location: { city?, country?: ISO2, region?, timezone?: IANA, type?: "approximate" }`. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WebSearchUserLocation { + /// City name. + pub city: Option, + /// ISO-3166-1 alpha-2 country code (e.g. `"US"`). + pub country: Option, + /// Region / state / province name. + pub region: Option, + /// IANA timezone identifier (e.g. `"America/Los_Angeles"`). + pub timezone: Option, + /// Discriminator. Spec only enumerates `"approximate"`. + #[serde(rename = "type")] + pub location_type: Option, +} + #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)] #[serde(deny_unknown_fields)] @@ -869,6 +939,12 @@ pub enum ResponseOutputItem { id: String, status: WebSearchCallStatus, action: WebSearchAction, + /// Search hits surfaced when callers request `web_search_call.results` + /// via the top-level `include[]` array. Mirrors the `file_search_call.results` + /// shape — array of typed entries when populated, omitted otherwise so the + /// default wire shape (`{id, action, status, type}`) stays spec-byte-identical. + #[serde(default, skip_serializing_if = "Option::is_none")] + results: Option>, }, #[serde(rename = "code_interpreter_call")] CodeInterpreterCall { @@ -930,6 +1006,25 @@ pub struct WebSearchSource { pub url: String, } +/// A single search result attached to a `WebSearchCall` when the caller +/// requested `web_search_call.results` via the top-level `include[]` array. +/// +/// Optional fields mirror the `FileSearchResult` shape — only `url` is +/// guaranteed; titles, snippets, and scores ride along when the upstream +/// search backend supplies them. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +pub struct WebSearchResult { + /// Canonical URL of the result. + pub url: String, + /// Page or document title, when surfaced by the search backend. + pub title: Option, + /// Short text snippet excerpted from the result. + pub snippet: Option, + /// Relevance score in `[0, 1]`, when the backend supplies one. + pub score: Option, +} + /// Status for code interpreter tool calls. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] @@ -2413,6 +2508,90 @@ mod tests { assert_eq!(serialized, payload); } + // ------------------------------------------------------------------ + // T3: non-preview web_search tool + WebSearchCall.results + // ------------------------------------------------------------------ + + /// Spec fixture (openai-responses-api-spec.md §tools line 439): + /// `{ type: "web_search" | "web_search_2025_08_26", filters? { allowed_domains? }, + /// search_context_size?: "low"|"medium"|"high", user_location? }`. Covers the + /// canonical tag, the versioned alias, and full-field nested shape. + #[test] + fn test_web_search_tool_round_trip() { + let payload = json!({ + "type": "web_search", + "filters": {"allowed_domains": ["example.com", "rust-lang.org"]}, + "search_context_size": "high", + "user_location": { + "type": "approximate", + "city": "San Francisco", + "country": "US", + "region": "California", + "timezone": "America/Los_Angeles" + } + }); + let tool: ResponseTool = + serde_json::from_value(payload.clone()).expect("web_search tool should deserialize"); + assert!(matches!(tool, ResponseTool::WebSearch(_))); + assert_eq!( + serde_json::to_value(&tool).expect("web_search tool should serialize"), + payload + ); + + // Versioned alias deserializes into the same variant (canonical serialization re-tested above). + let alias: ResponseTool = serde_json::from_value(json!({"type": "web_search_2025_08_26"})) + .expect("web_search_2025_08_26 alias should deserialize"); + assert!(matches!(alias, ResponseTool::WebSearch(_))); + } + + /// Acceptance: `web_search_call` output item carries an optional typed + /// `results` field populated when callers request `web_search_call.results` + /// via the top-level `include[]` array. When absent, the default wire shape + /// `{id, action, status, type}` must stay spec-byte-identical. + #[test] + fn test_web_search_call_results_round_trip() { + let with_results = json!({ + "type": "web_search_call", + "id": "ws_abc", + "status": "completed", + "action": {"type": "search", "query": "rust", "queries": ["rust"]}, + "results": [ + {"url": "https://tokio.rs", "title": "Tokio", "snippet": "rt", "score": 0.5}, + {"url": "https://async.rs"} + ] + }); + let item: ResponseOutputItem = serde_json::from_value(with_results.clone()) + .expect("web_search_call with results should deserialize"); + let ResponseOutputItem::WebSearchCall { results, .. } = &item else { + panic!("expected WebSearchCall"); + }; + let results = results.as_ref().expect("results present"); + assert_eq!(results.len(), 2); + assert_eq!(results[0].score, Some(0.5)); + assert!(results[1].title.is_none()); + assert_eq!( + serde_json::to_value(&item).expect("web_search_call should serialize"), + with_results + ); + + // Absent results: deserializes to None and re-serializes without the key. + let no_results = json!({ + "type": "web_search_call", + "id": "ws_no", + "status": "completed", + "action": {"type": "search"} + }); + let bare: ResponseOutputItem = serde_json::from_value(no_results.clone()) + .expect("web_search_call without results should deserialize"); + let ResponseOutputItem::WebSearchCall { results, .. } = &bare else { + panic!("expected WebSearchCall"); + }; + assert!(results.is_none()); + let serialized = serde_json::to_value(&bare).expect("web_search_call should serialize"); + assert_eq!(serialized, no_results); + assert!(serialized.get("results").is_none()); + } + // ------------------------------------------------------------------ // P2: new top-level ResponsesRequest fields // ------------------------------------------------------------------ diff --git a/model_gateway/src/routers/grpc/harmony/builder.rs b/model_gateway/src/routers/grpc/harmony/builder.rs index e0a2f6c8b6..687c6b69e6 100644 --- a/model_gateway/src/routers/grpc/harmony/builder.rs +++ b/model_gateway/src/routers/grpc/harmony/builder.rs @@ -429,6 +429,7 @@ impl HarmonyBuilder { .map(|tool| match tool { ResponseTool::Function(_) => "function", ResponseTool::WebSearchPreview(_) => "web_search_preview", + ResponseTool::WebSearch(_) => "web_search", ResponseTool::CodeInterpreter(_) => "code_interpreter", ResponseTool::Mcp(_) => "mcp", ResponseTool::FileSearch(_) => "file_search", diff --git a/model_gateway/src/routers/openai/responses/utils.rs b/model_gateway/src/routers/openai/responses/utils.rs index 43962342a3..f751bee299 100644 --- a/model_gateway/src/routers/openai/responses/utils.rs +++ b/model_gateway/src/routers/openai/responses/utils.rs @@ -209,8 +209,9 @@ pub(super) fn insert_optional_value( /// Convert a single ResponseTool back to its original JSON representation. /// -/// Handles MCP tools (with server metadata), web_search_preview, and code_interpreter. -/// Returns None for function tools and other types that don't need restoration. +/// Handles MCP tools (with server metadata), web_search, web_search_preview, +/// file_search, and code_interpreter. Returns None for function tools and other +/// types that don't need restoration. pub(super) fn response_tool_to_value(tool: &ResponseTool) -> Option { match tool { ResponseTool::Mcp(mcp) => { @@ -233,6 +234,7 @@ pub(super) fn response_tool_to_value(tool: &ResponseTool) -> Option { Some(Value::Object(m)) } ResponseTool::WebSearchPreview(_) => serde_json::to_value(tool).ok(), + ResponseTool::WebSearch(_) => serde_json::to_value(tool).ok(), ResponseTool::CodeInterpreter(_) => serde_json::to_value(tool).ok(), ResponseTool::FileSearch(_) => serde_json::to_value(tool).ok(), ResponseTool::Function(_) => None, From b2485f0d989db98fdb924e5364f5b59595cf1c5b Mon Sep 17 00:00:00 2001 From: Simo Lin Date: Tue, 21 Apr 2026 21:38:35 -0700 Subject: [PATCH 2/2] fix(gateway): address T3 bot-review feedback (web_search BUILTIN_TOOLS + is_builtin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude nit on PR #1304 comment 3121138818: adding `"web_search"` to the tool_types collection in `extract_tool_types_from_response_tools` without also adding it to the `BUILTIN_TOOLS` slice and `ToolLike::is_builtin()` for `ResponseTool` meant `has_custom_tools` would misclassify web-search- only requests as custom. Follow the T1 file_search pattern: add `"web_search"` to the BUILTIN_TOOLS slice at L63 and add `ResponseTool::WebSearch(_)` to the `matches!` arm in `is_builtin()` at L108-112. Codex P1 comment 3121145843 (MCP routing for web_search) deliberately left for a follow-up task — the proper fix spans the `BuiltinToolType` enum in `crates/mcp/src/core/config.rs` and its ~30 callsites plus `grpc/common/responses/utils.rs::ensure_mcp_connection`, which is out of T3's protocol-only charter. Refs: T3 Signed-off-by: Simo Lin --- model_gateway/src/routers/grpc/harmony/builder.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/model_gateway/src/routers/grpc/harmony/builder.rs b/model_gateway/src/routers/grpc/harmony/builder.rs index 687c6b69e6..a989fbf839 100644 --- a/model_gateway/src/routers/grpc/harmony/builder.rs +++ b/model_gateway/src/routers/grpc/harmony/builder.rs @@ -62,6 +62,7 @@ pub(crate) fn convert_harmony_logprobs(proto_logprobs: &ProtoOutputLogProbs) -> /// Built-in tools that are added to the system message const BUILTIN_TOOLS: &[&str] = &[ "web_search_preview", + "web_search", "code_interpreter", "container", "file_search", @@ -107,7 +108,9 @@ impl ToolLike for ResponseTool { fn is_builtin(&self) -> bool { matches!( self, - ResponseTool::WebSearchPreview(_) | ResponseTool::CodeInterpreter(_) + ResponseTool::WebSearchPreview(_) + | ResponseTool::WebSearch(_) + | ResponseTool::CodeInterpreter(_) ) }