1use std::collections::HashMap;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::types::io::output::{FunctionToolCall, WebSearchCall, WebSearchCallStatus, WebSearchSource};
10use crate::types::io::{FunctionTool, OutputItem};
11use crate::types::tools::{WebSearchContextSize, WebSearchToolParam};
12use crate::utils::common::{serialize_to_string, serialize_to_value_or_custom_default};
13
14use super::handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput};
15use super::registry::{ToolEntry, ToolType};
16
17const YOU_API_KEY: &str = "YOU_API_KEY";
18const YOU_API_BASE_URL: &str = "YOU_API_BASE_URL";
19
20pub(crate) fn insert_web_search_entry(
21 entries: &mut HashMap<String, ToolEntry>,
22 p: &WebSearchToolParam,
23 handler: Option<Arc<dyn GatewayExecutor>>,
24) {
25 serialize_to_value_or_custom_default(
26 p,
27 "web_search tool config serialization failed",
28 |config| {
29 entries.insert(
30 "web_search".to_owned(),
31 ToolEntry {
32 tool_type: ToolType::WebSearch,
33 config,
34 server_label: None,
35 handler,
36 },
37 );
38 },
39 (),
40 );
41}
42
43#[must_use]
44pub(crate) fn web_search_function_tool() -> FunctionTool {
45 FunctionTool {
46 type_: "function".to_owned(),
47 name: "web_search".to_owned(),
48 description: Some(
49 "Search the public web for current information and return structured web and news results.".to_owned(),
50 ),
51 parameters: Some(serde_json::json!({
52 "type": "object",
53 "properties": {
54 "query": {
55 "type": "string",
56 "description": "The natural language web search query."
57 },
58 "count": {
59 "type": "integer",
60 "description": "Maximum results per section, from 1 to 100."
61 },
62 "freshness": {
63 "type": "string",
64 "description": "Optional recency filter: day, week, month, year, or YYYY-MM-DDtoYYYY-MM-DD."
65 },
66 "country": {
67 "type": "string",
68 "description": "Optional ISO 3166-1 alpha-2 country code."
69 },
70 "language": {
71 "type": "string",
72 "description": "Optional BCP 47 language code."
73 },
74 "include_domains": {
75 "type": "array",
76 "items": {"type": "string"},
77 "description": "Optional strict allowlist of domains."
78 },
79 "exclude_domains": {
80 "type": "array",
81 "items": {"type": "string"},
82 "description": "Optional domain blocklist."
83 }
84 },
85 "required": ["query"]
86 })),
87 strict: Some(false),
88 }
89}
90
91#[must_use]
92pub(crate) fn output_item(call: &FunctionToolCall, output: &ToolOutput, status: WebSearchCallStatus) -> OutputItem {
93 let parsed_output = serde_json::from_str::<Value>(&output.output).ok();
94 let query = parsed_output
95 .as_ref()
96 .and_then(|value| clean_json_str(value.get("query")))
97 .or_else(|| query_from_arguments(&call.arguments))
98 .unwrap_or_default();
99 let sources = parsed_output.as_ref().map(sources_from_output).unwrap_or_default();
100 OutputItem::WebSearchCall(WebSearchCall::new(call_output_id(call), status, query, sources))
101}
102
103#[must_use]
104pub(crate) fn started_output_item(call: &FunctionToolCall) -> OutputItem {
105 OutputItem::WebSearchCall(WebSearchCall::new(
106 call_output_id(call),
107 WebSearchCallStatus::InProgress,
108 query_from_arguments(&call.arguments).unwrap_or_default(),
109 Vec::new(),
110 ))
111}
112
113#[derive(Debug, Clone)]
114pub struct WebSearchHandler {
115 provider: Arc<dyn WebSearchProvider>,
116}
117
118impl WebSearchHandler {
119 #[must_use]
120 pub fn from_env(client: Arc<reqwest::Client>) -> Self {
121 Self {
122 provider: Arc::new(YouSearchProvider::from_env(client)),
123 }
124 }
125
126 #[must_use]
127 pub fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
128 Self {
129 provider: Arc::new(YouSearchProvider::with_api_key(client, api_key, base_url)),
130 }
131 }
132
133 #[cfg(test)]
134 fn with_provider(provider: Arc<dyn WebSearchProvider>) -> Self {
135 Self { provider }
136 }
137
138 async fn execute_search(&self, call_id: &str, arguments: &str, config: &Value) -> Result<ToolOutput, ToolError> {
139 let args = WebSearchArguments::from_json(arguments)?;
140 let config = serde_json::from_value::<WebSearchToolParam>(config.clone())
141 .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))?;
142 let response = self.provider.search(&args, &config).await?;
143 let output = serde_json::to_string(&serde_json::json!({
144 "query": response.query,
145 "results": response.results,
146 "metadata": response.metadata
147 }))
148 .map_err(|e| ToolError::Execution(format!("failed to serialize web_search output: {e}")))?;
149
150 Ok(ToolOutput {
151 call_id: call_id.to_owned(),
152 output,
153 })
154 }
155}
156
157trait WebSearchProvider: std::fmt::Debug + Send + Sync {
158 fn search<'a>(
159 &'a self,
160 args: &'a WebSearchArguments,
161 config: &'a WebSearchToolParam,
162 ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>>;
163}
164
165struct WebSearchProviderResponse {
166 query: String,
167 results: Value,
168 metadata: Value,
169}
170
171#[derive(Debug, Clone)]
172struct YouSearchProvider {
173 client: Arc<reqwest::Client>,
174 api_key: Option<String>,
175 base_url: Option<String>,
176}
177
178impl YouSearchProvider {
179 fn from_env(client: Arc<reqwest::Client>) -> Self {
180 let api_key = std::env::var(YOU_API_KEY)
181 .ok()
182 .map(|value| value.trim().to_owned())
183 .filter(|value| !value.is_empty());
184 let base_url = std::env::var(YOU_API_BASE_URL)
185 .ok()
186 .and_then(|value| clean_base_url(&value));
187 Self {
188 client,
189 api_key,
190 base_url,
191 }
192 }
193
194 fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
195 Self {
196 client,
197 api_key: Some(api_key),
198 base_url: clean_base_url(base_url),
199 }
200 }
201}
202
203impl WebSearchProvider for YouSearchProvider {
204 fn search<'a>(
205 &'a self,
206 args: &'a WebSearchArguments,
207 config: &'a WebSearchToolParam,
208 ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
209 Box::pin(async move {
210 let api_key = self
211 .api_key
212 .as_deref()
213 .ok_or_else(|| ToolError::Config(format!("{YOU_API_KEY} must be set to use the web_search tool")))?;
214 let base_url = self.base_url.as_deref().ok_or_else(|| {
215 ToolError::Config(format!("{YOU_API_BASE_URL} must be set to use the web_search tool"))
216 })?;
217 let request = YouSearchRequest::from_args_and_config(args, config)?;
218 let url = format!("{base_url}/v1/search");
219 let body = serialize_to_string(&request)
220 .map_err(|e| ToolError::Execution(format!("failed to serialize web_search request: {e}")))?;
221
222 let resp = self
223 .client
224 .post(url)
225 .header("X-API-Key", api_key)
226 .header("Content-Type", "application/json")
227 .body(body)
228 .send()
229 .await
230 .map_err(|e| ToolError::Execution(format!("You.com search request failed: {e}")))?;
231
232 if !resp.status().is_success() {
233 let status = resp.status();
234 let body = resp.text().await.unwrap_or_default();
235 return Err(ToolError::Execution(format!(
236 "You.com search returned {status}: {body}"
237 )));
238 }
239
240 let response_text = resp
241 .text()
242 .await
243 .map_err(|e| ToolError::Execution(format!("failed to read You.com search response: {e}")))?;
244 let response: Value = serde_json::from_str(&response_text)
245 .map_err(|e| ToolError::Execution(format!("You.com search returned invalid JSON: {e}")))?;
246 Ok(WebSearchProviderResponse {
247 query: request.query,
248 results: response
249 .get("results")
250 .cloned()
251 .unwrap_or_else(|| serde_json::json!({"web": [], "news": []})),
252 metadata: response.get("metadata").cloned().unwrap_or(Value::Null),
253 })
254 })
255 }
256}
257
258impl ToolHandler for WebSearchHandler {
259 fn tool_type(&self) -> ToolType {
260 ToolType::WebSearch
261 }
262
263 fn validate(&self, param: &Value) -> Result<(), ToolError> {
264 serde_json::from_value::<WebSearchToolParam>(param.clone())
265 .map(|_| ())
266 .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))
267 }
268
269 fn normalize(&self, _param: &Value) -> Vec<FunctionTool> {
270 vec![web_search_function_tool()]
271 }
272}
273
274impl GatewayExecutor for WebSearchHandler {
275 fn execute(
276 &self,
277 call_id: &str,
278 tool_name: &str,
279 arguments: &str,
280 config: &Value,
281 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
282 let call_id = call_id.to_owned();
283 let tool_name = tool_name.to_owned();
284 let arguments = arguments.to_owned();
285 let config = config.clone();
286 Box::pin(async move {
287 if tool_name != "web_search" {
288 return Err(ToolError::Config(format!(
289 "web_search handler cannot execute tool '{tool_name}'"
290 )));
291 }
292 self.execute_search(&call_id, &arguments, &config).await
293 })
294 }
295}
296
297#[derive(Debug, Deserialize)]
298struct WebSearchArguments {
299 query: String,
300 count: Option<u16>,
301 freshness: Option<String>,
302 country: Option<String>,
303 language: Option<String>,
304 safesearch: Option<String>,
305 livecrawl: Option<String>,
306 livecrawl_formats: Option<Vec<String>>,
307 crawl_timeout: Option<u16>,
308 include_domains: Option<Vec<String>>,
309 exclude_domains: Option<Vec<String>>,
310 boost_domains: Option<Vec<String>>,
311}
312
313impl WebSearchArguments {
314 fn from_json(arguments: &str) -> Result<Self, ToolError> {
315 let args = serde_json::from_str::<Self>(arguments)
316 .map_err(|e| ToolError::Config(format!("web_search arguments must be valid JSON: {e}")))?;
317 if args.query.trim().is_empty() {
318 return Err(ToolError::Config("web_search query must not be empty".to_owned()));
319 }
320 Ok(args)
321 }
322}
323
324#[derive(Debug, Serialize)]
325struct YouSearchRequest {
326 query: String,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 count: Option<u8>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 freshness: Option<String>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 country: Option<String>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 language: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 safesearch: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 livecrawl: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 livecrawl_formats: Option<Vec<String>>,
341 #[serde(skip_serializing_if = "Option::is_none")]
342 crawl_timeout: Option<u8>,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 include_domains: Option<Vec<String>>,
345 #[serde(skip_serializing_if = "Option::is_none")]
346 exclude_domains: Option<Vec<String>>,
347 #[serde(skip_serializing_if = "Option::is_none")]
348 boost_domains: Option<Vec<String>>,
349}
350
351impl YouSearchRequest {
352 fn from_args_and_config(args: &WebSearchArguments, config: &WebSearchToolParam) -> Result<Self, ToolError> {
353 let count = args
354 .count
355 .or_else(|| {
356 config
357 .search_context_size
358 .map(WebSearchContextSize::default_count)
359 .map(u16::from)
360 })
361 .map(validate_count)
362 .transpose()?;
363 let crawl_timeout = args.crawl_timeout.map(validate_crawl_timeout).transpose()?;
364 let config_domains = config
365 .filters
366 .as_ref()
367 .and_then(|filters| clean_vec(filters.allowed_domains.as_deref()));
368 let config_blocked_domains = config
369 .filters
370 .as_ref()
371 .and_then(|filters| clean_vec(filters.blocked_domains.as_deref()));
372 let include_domains = config_domains.or_else(|| clean_vec(args.include_domains.as_deref()));
373 let exclude_domains = config_blocked_domains.or_else(|| clean_vec(args.exclude_domains.as_deref()));
374 let boost_domains = clean_vec(args.boost_domains.as_deref());
375 if include_domains.is_some() && (exclude_domains.is_some() || boost_domains.is_some()) {
376 return Err(ToolError::Config(
377 "include_domains cannot be combined with exclude_domains or boost_domains".to_owned(),
378 ));
379 }
380 let country = config
381 .user_location
382 .as_ref()
383 .and_then(|location| clean_string(location.country.as_deref()))
384 .or_else(|| clean_string(args.country.as_deref()))
385 .map(|value| value.to_ascii_uppercase());
386
387 Ok(Self {
388 query: args.query.trim().to_owned(),
389 count,
390 freshness: clean_string(args.freshness.as_deref()),
391 country,
392 language: clean_string(args.language.as_deref()),
393 safesearch: clean_string(args.safesearch.as_deref()),
394 livecrawl: clean_string(args.livecrawl.as_deref()),
395 livecrawl_formats: clean_vec(args.livecrawl_formats.as_deref()),
396 crawl_timeout,
397 include_domains,
398 exclude_domains,
399 boost_domains,
400 })
401 }
402}
403
404fn validate_count(count: u16) -> Result<u8, ToolError> {
405 if (1..=100).contains(&count) {
406 Ok(u8::try_from(count).expect("validated web_search count must fit in u8"))
407 } else {
408 Err(ToolError::Config(
409 "web_search count must be between 1 and 100".to_owned(),
410 ))
411 }
412}
413
414fn validate_crawl_timeout(timeout: u16) -> Result<u8, ToolError> {
415 if (1..=60).contains(&timeout) {
416 u8::try_from(timeout).map_err(|e| ToolError::Config(format!("invalid crawl_timeout: {e}")))
417 } else {
418 Err(ToolError::Config(
419 "web_search crawl_timeout must be between 1 and 60".to_owned(),
420 ))
421 }
422}
423
424fn clean_string(value: Option<&str>) -> Option<String> {
425 value
426 .map(str::trim)
427 .filter(|value| !value.is_empty())
428 .map(str::to_owned)
429}
430
431fn clean_json_str(value: Option<&Value>) -> Option<String> {
432 value
433 .and_then(Value::as_str)
434 .map(str::trim)
435 .filter(|value| !value.is_empty())
436 .map(str::to_owned)
437}
438
439fn call_output_id(call: &FunctionToolCall) -> String {
440 if let Some(suffix) = call.id.strip_prefix("fc_").filter(|suffix| !suffix.is_empty()) {
441 return format!("ws_{suffix}");
442 }
443 if let Some(suffix) = call.call_id.strip_prefix("call_").filter(|suffix| !suffix.is_empty()) {
444 return format!("ws_{suffix}");
445 }
446 crate::utils::uuid7_str("ws_")
447}
448
449fn query_from_arguments(arguments: &str) -> Option<String> {
450 let args = serde_json::from_str::<Value>(arguments).ok()?;
451 clean_json_str(args.get("query"))
452}
453
454fn sources_from_output(output: &Value) -> Vec<WebSearchSource> {
455 ["web", "news"]
456 .into_iter()
457 .filter_map(|section| output.get("results")?.get(section)?.as_array())
458 .flat_map(|results| results.iter())
459 .filter_map(source_from_result)
460 .collect()
461}
462
463fn source_from_result(result: &Value) -> Option<WebSearchSource> {
464 let url = clean_json_str(result.get("url"))?;
465 Some(WebSearchSource {
466 url,
467 title: clean_json_str(result.get("title")),
468 })
469}
470
471fn clean_base_url(value: &str) -> Option<String> {
472 let trimmed = value.trim().trim_end_matches('/');
473 (!trimmed.is_empty()).then(|| trimmed.to_owned())
474}
475
476fn clean_vec(values: Option<&[String]>) -> Option<Vec<String>> {
477 let cleaned: Vec<String> = values
478 .unwrap_or_default()
479 .iter()
480 .filter_map(|value| clean_string(Some(value.as_str())))
481 .collect();
482 (!cleaned.is_empty()).then_some(cleaned)
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[derive(Debug)]
490 struct MockSearchProvider;
491
492 impl WebSearchProvider for MockSearchProvider {
493 fn search<'a>(
494 &'a self,
495 args: &'a WebSearchArguments,
496 _config: &'a WebSearchToolParam,
497 ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
498 Box::pin(async move {
499 Ok(WebSearchProviderResponse {
500 query: args.query.trim().to_owned(),
501 results: serde_json::json!({
502 "web": [
503 {
504 "url": "https://example.com/potato",
505 "title": "Potato"
506 }
507 ],
508 "news": []
509 }),
510 metadata: serde_json::json!({"provider": "mock"}),
511 })
512 })
513 }
514 }
515
516 #[tokio::test]
517 async fn web_search_handler_delegates_to_provider() {
518 let handler = WebSearchHandler::with_provider(Arc::new(MockSearchProvider));
519 let output = handler
520 .execute(
521 "call_search",
522 "web_search",
523 r#"{"query":" potato "}"#,
524 &serde_json::json!({"type": "web_search_preview"}),
525 )
526 .await
527 .unwrap();
528 let body: Value = serde_json::from_str(&output.output).unwrap();
529 assert_eq!(output.call_id, "call_search");
530 assert_eq!(body["query"], "potato");
531 assert_eq!(body["metadata"]["provider"], "mock");
532 assert_eq!(body["results"]["web"][0]["url"], "https://example.com/potato");
533 }
534}