Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/mcp/src/transform/transformer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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,
Expand Down
179 changes: 179 additions & 0 deletions crates/protocols/src/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment thread
slin1237 marked this conversation as resolved.

/// Built-in tool.
#[serde(rename = "code_interpreter")]
CodeInterpreter(CodeInterpreterTool),
Expand Down Expand Up @@ -484,6 +493,67 @@ pub struct WebSearchPreviewTool {
pub user_location: Option<Value>,
}

/// 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<WebSearchFilters>,
/// Search context budget. Spec enum: `"low" | "medium" | "high"`.
pub search_context_size: Option<WebSearchContextSize>,
/// Approximate user location used to bias results.
pub user_location: Option<WebSearchUserLocation>,
}

/// 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<Vec<String>>,
}

/// 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<String>,
/// ISO-3166-1 alpha-2 country code (e.g. `"US"`).
pub country: Option<String>,
/// Region / state / province name.
pub region: Option<String>,
/// IANA timezone identifier (e.g. `"America/Los_Angeles"`).
pub timezone: Option<String>,
/// Discriminator. Spec only enumerates `"approximate"`.
#[serde(rename = "type")]
pub location_type: Option<String>,
}

#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -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<Vec<WebSearchResult>>,
},
#[serde(rename = "code_interpreter_call")]
CodeInterpreterCall {
Expand Down Expand Up @@ -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<String>,
/// Short text snippet excerpted from the result.
pub snippet: Option<String>,
/// Relevance score in `[0, 1]`, when the backend supplies one.
pub score: Option<f32>,
}

/// Status for code interpreter tool calls.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -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
// ------------------------------------------------------------------
Expand Down
6 changes: 5 additions & 1 deletion model_gateway/src/routers/grpc/harmony/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(_)
)
}

Expand Down Expand Up @@ -429,6 +432,7 @@ impl HarmonyBuilder {
.map(|tool| match tool {
ResponseTool::Function(_) => "function",
ResponseTool::WebSearchPreview(_) => "web_search_preview",
ResponseTool::WebSearch(_) => "web_search",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: "web_search" is added to the tool_types collection here, but the BUILTIN_TOOLS constant (line 63) and ToolLike::is_builtin() impl for ResponseTool (line 110) in this same file were not updated to include the new variant.

This means has_custom_tools(&tool_types) at line 441 will return true when web_search is the only tool in the request (since "web_search" is not in BUILTIN_TOOLS), incorrectly triggering the developer-message injection path.

Similarly, collect_builtin_routing and extract_builtin_types in mcp_utils.rs, and ensure_mcp_connection in grpc/common/responses/utils.rs, don't recognize ResponseTool::WebSearch as a builtin — though that full fix also needs a BuiltinToolType::WebSearch enum variant in crates/mcp/src/core/config.rs. If MCP routing for the non-preview variant is intentionally deferred, at minimum BUILTIN_TOOLS and is_builtin() in this file should be updated.

ResponseTool::CodeInterpreter(_) => "code_interpreter",
ResponseTool::Mcp(_) => "mcp",
ResponseTool::FileSearch(_) => "file_search",
Expand Down
6 changes: 4 additions & 2 deletions model_gateway/src/routers/openai/responses/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,9 @@ pub(super) fn insert_optional_value<T: Serialize>(

/// 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<Value> {
match tool {
ResponseTool::Mcp(mcp) => {
Expand All @@ -233,6 +234,7 @@ pub(super) fn response_tool_to_value(tool: &ResponseTool) -> Option<Value> {
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,
Expand Down
Loading