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::from_values(
122 client,
123 std::env::var(YOU_API_KEY).ok(),
124 std::env::var(YOU_API_BASE_URL).ok(),
125 )
126 }
127
128 #[must_use]
129 pub fn from_values(client: Arc<reqwest::Client>, api_key: Option<String>, base_url: Option<String>) -> Self {
130 Self {
131 provider: Arc::new(YouSearchProvider::from_values(client, api_key, base_url)),
132 }
133 }
134
135 #[must_use]
136 pub fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
137 Self {
138 provider: Arc::new(YouSearchProvider::with_api_key(client, api_key, base_url)),
139 }
140 }
141
142 #[cfg(test)]
143 fn with_provider(provider: Arc<dyn WebSearchProvider>) -> Self {
144 Self { provider }
145 }
146
147 async fn execute_search(&self, call_id: &str, arguments: &str, config: &Value) -> Result<ToolOutput, ToolError> {
148 let args = WebSearchArguments::from_json(arguments)?;
149 let config = serde_json::from_value::<WebSearchToolParam>(config.clone())
150 .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))?;
151 let response = self.provider.search(&args, &config).await?;
152 let output = serde_json::to_string(&serde_json::json!({
153 "query": response.query,
154 "results": response.results,
155 "metadata": response.metadata
156 }))
157 .map_err(|e| ToolError::Execution(format!("failed to serialize web_search output: {e}")))?;
158
159 Ok(ToolOutput {
160 call_id: call_id.to_owned(),
161 output,
162 })
163 }
164}
165
166trait WebSearchProvider: std::fmt::Debug + Send + Sync {
167 fn search<'a>(
168 &'a self,
169 args: &'a WebSearchArguments,
170 config: &'a WebSearchToolParam,
171 ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>>;
172}
173
174struct WebSearchProviderResponse {
175 query: String,
176 results: Value,
177 metadata: Value,
178}
179
180#[derive(Debug, Clone)]
181struct YouSearchProvider {
182 client: Arc<reqwest::Client>,
183 api_key: Option<String>,
184 base_url: Option<String>,
185}
186
187impl YouSearchProvider {
188 fn from_values(client: Arc<reqwest::Client>, api_key: Option<String>, base_url: Option<String>) -> Self {
189 let api_key = api_key
190 .map(|value| value.trim().to_owned())
191 .filter(|value| !value.is_empty());
192 let base_url = base_url.and_then(|value| clean_base_url(&value));
193 Self {
194 client,
195 api_key,
196 base_url,
197 }
198 }
199
200 fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
201 Self {
202 client,
203 api_key: Some(api_key),
204 base_url: clean_base_url(base_url),
205 }
206 }
207}
208
209impl WebSearchProvider for YouSearchProvider {
210 fn search<'a>(
211 &'a self,
212 args: &'a WebSearchArguments,
213 config: &'a WebSearchToolParam,
214 ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
215 Box::pin(async move {
216 let api_key = self
217 .api_key
218 .as_deref()
219 .ok_or_else(|| ToolError::Config(format!("{YOU_API_KEY} must be set to use the web_search tool")))?;
220 let base_url = self.base_url.as_deref().ok_or_else(|| {
221 ToolError::Config(format!("{YOU_API_BASE_URL} must be set to use the web_search tool"))
222 })?;
223 let request = YouSearchRequest::from_args_and_config(args, config)?;
224 let url = format!("{base_url}/v1/search");
225 let body = serialize_to_string(&request)
226 .map_err(|e| ToolError::Execution(format!("failed to serialize web_search request: {e}")))?;
227
228 let resp = self
229 .client
230 .post(url)
231 .header("X-API-Key", api_key)
232 .header("Content-Type", "application/json")
233 .body(body)
234 .send()
235 .await
236 .map_err(|e| ToolError::Execution(format!("You.com search request failed: {e}")))?;
237
238 if !resp.status().is_success() {
239 let status = resp.status();
240 let body = resp.text().await.unwrap_or_default();
241 return Err(ToolError::Execution(format!(
242 "You.com search returned {status}: {body}"
243 )));
244 }
245
246 let response_text = resp
247 .text()
248 .await
249 .map_err(|e| ToolError::Execution(format!("failed to read You.com search response: {e}")))?;
250 let response: Value = serde_json::from_str(&response_text)
251 .map_err(|e| ToolError::Execution(format!("You.com search returned invalid JSON: {e}")))?;
252 Ok(WebSearchProviderResponse {
253 query: request.query,
254 results: response
255 .get("results")
256 .cloned()
257 .unwrap_or_else(|| serde_json::json!({"web": [], "news": []})),
258 metadata: response.get("metadata").cloned().unwrap_or(Value::Null),
259 })
260 })
261 }
262}
263
264impl ToolHandler for WebSearchHandler {
265 fn tool_type(&self) -> ToolType {
266 ToolType::WebSearch
267 }
268
269 fn validate(&self, param: &Value) -> Result<(), ToolError> {
270 serde_json::from_value::<WebSearchToolParam>(param.clone())
271 .map(|_| ())
272 .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))
273 }
274
275 fn normalize(&self, _param: &Value) -> Vec<FunctionTool> {
276 vec![web_search_function_tool()]
277 }
278}
279
280impl GatewayExecutor for WebSearchHandler {
281 fn execute(
282 &self,
283 call_id: &str,
284 tool_name: &str,
285 arguments: &str,
286 config: &Value,
287 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
288 let call_id = call_id.to_owned();
289 let tool_name = tool_name.to_owned();
290 let arguments = arguments.to_owned();
291 let config = config.clone();
292 Box::pin(async move {
293 if tool_name != "web_search" {
294 return Err(ToolError::Config(format!(
295 "web_search handler cannot execute tool '{tool_name}'"
296 )));
297 }
298 self.execute_search(&call_id, &arguments, &config).await
299 })
300 }
301}
302
303#[derive(Debug, Deserialize)]
304struct WebSearchArguments {
305 query: String,
306 count: Option<u16>,
307 freshness: Option<String>,
308 country: Option<String>,
309 language: Option<String>,
310 safesearch: Option<String>,
311 livecrawl: Option<String>,
312 livecrawl_formats: Option<Vec<String>>,
313 crawl_timeout: Option<u16>,
314 include_domains: Option<Vec<String>>,
315 exclude_domains: Option<Vec<String>>,
316 boost_domains: Option<Vec<String>>,
317}
318
319impl WebSearchArguments {
320 fn from_json(arguments: &str) -> Result<Self, ToolError> {
321 let args = serde_json::from_str::<Self>(arguments)
322 .map_err(|e| ToolError::Config(format!("web_search arguments must be valid JSON: {e}")))?;
323 if args.query.trim().is_empty() {
324 return Err(ToolError::Config("web_search query must not be empty".to_owned()));
325 }
326 Ok(args)
327 }
328}
329
330#[derive(Debug, Serialize)]
331struct YouSearchRequest {
332 query: String,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 count: Option<u8>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 freshness: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 country: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 language: Option<String>,
341 #[serde(skip_serializing_if = "Option::is_none")]
342 safesearch: Option<String>,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 livecrawl: Option<String>,
345 #[serde(skip_serializing_if = "Option::is_none")]
346 livecrawl_formats: Option<Vec<String>>,
347 #[serde(skip_serializing_if = "Option::is_none")]
348 crawl_timeout: Option<u8>,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 include_domains: Option<Vec<String>>,
351 #[serde(skip_serializing_if = "Option::is_none")]
352 exclude_domains: Option<Vec<String>>,
353 #[serde(skip_serializing_if = "Option::is_none")]
354 boost_domains: Option<Vec<String>>,
355}
356
357impl YouSearchRequest {
358 fn from_args_and_config(args: &WebSearchArguments, config: &WebSearchToolParam) -> Result<Self, ToolError> {
359 let count = args
360 .count
361 .or_else(|| {
362 config
363 .search_context_size
364 .map(WebSearchContextSize::default_count)
365 .map(u16::from)
366 })
367 .map(validate_count)
368 .transpose()?;
369 let crawl_timeout = args.crawl_timeout.map(validate_crawl_timeout).transpose()?;
370 let config_domains = config
371 .filters
372 .as_ref()
373 .and_then(|filters| clean_vec(filters.allowed_domains.as_deref()));
374 let config_blocked_domains = config
375 .filters
376 .as_ref()
377 .and_then(|filters| clean_vec(filters.blocked_domains.as_deref()));
378 let include_domains = config_domains.or_else(|| clean_vec(args.include_domains.as_deref()));
379 let exclude_domains = config_blocked_domains.or_else(|| clean_vec(args.exclude_domains.as_deref()));
380 let boost_domains = clean_vec(args.boost_domains.as_deref());
381 if include_domains.is_some() && (exclude_domains.is_some() || boost_domains.is_some()) {
382 return Err(ToolError::Config(
383 "include_domains cannot be combined with exclude_domains or boost_domains".to_owned(),
384 ));
385 }
386 let country = config
387 .user_location
388 .as_ref()
389 .and_then(|location| clean_string(location.country.as_deref()))
390 .or_else(|| clean_string(args.country.as_deref()))
391 .map(|value| value.to_ascii_uppercase());
392
393 Ok(Self {
394 query: args.query.trim().to_owned(),
395 count,
396 freshness: clean_string(args.freshness.as_deref()),
397 country,
398 language: clean_string(args.language.as_deref()),
399 safesearch: clean_string(args.safesearch.as_deref()),
400 livecrawl: clean_string(args.livecrawl.as_deref()),
401 livecrawl_formats: clean_vec(args.livecrawl_formats.as_deref()),
402 crawl_timeout,
403 include_domains,
404 exclude_domains,
405 boost_domains,
406 })
407 }
408}
409
410fn validate_count(count: u16) -> Result<u8, ToolError> {
411 if (1..=100).contains(&count) {
412 Ok(u8::try_from(count).expect("validated web_search count must fit in u8"))
413 } else {
414 Err(ToolError::Config(
415 "web_search count must be between 1 and 100".to_owned(),
416 ))
417 }
418}
419
420fn validate_crawl_timeout(timeout: u16) -> Result<u8, ToolError> {
421 if (1..=60).contains(&timeout) {
422 u8::try_from(timeout).map_err(|e| ToolError::Config(format!("invalid crawl_timeout: {e}")))
423 } else {
424 Err(ToolError::Config(
425 "web_search crawl_timeout must be between 1 and 60".to_owned(),
426 ))
427 }
428}
429
430fn clean_string(value: Option<&str>) -> Option<String> {
431 value
432 .map(str::trim)
433 .filter(|value| !value.is_empty())
434 .map(str::to_owned)
435}
436
437fn clean_json_str(value: Option<&Value>) -> Option<String> {
438 value
439 .and_then(Value::as_str)
440 .map(str::trim)
441 .filter(|value| !value.is_empty())
442 .map(str::to_owned)
443}
444
445fn call_output_id(call: &FunctionToolCall) -> String {
446 if let Some(suffix) = call.id.strip_prefix("fc_").filter(|suffix| !suffix.is_empty()) {
447 return format!("ws_{suffix}");
448 }
449 if let Some(suffix) = call.call_id.strip_prefix("call_").filter(|suffix| !suffix.is_empty()) {
450 return format!("ws_{suffix}");
451 }
452 crate::utils::uuid7_str("ws_")
453}
454
455fn query_from_arguments(arguments: &str) -> Option<String> {
456 let args = serde_json::from_str::<Value>(arguments).ok()?;
457 clean_json_str(args.get("query"))
458}
459
460fn sources_from_output(output: &Value) -> Vec<WebSearchSource> {
461 ["web", "news"]
462 .into_iter()
463 .filter_map(|section| output.get("results")?.get(section)?.as_array())
464 .flat_map(|results| results.iter())
465 .filter_map(source_from_result)
466 .collect()
467}
468
469fn source_from_result(result: &Value) -> Option<WebSearchSource> {
470 let url = clean_json_str(result.get("url"))?;
471 Some(WebSearchSource {
472 url,
473 title: clean_json_str(result.get("title")),
474 })
475}
476
477fn clean_base_url(value: &str) -> Option<String> {
478 let trimmed = value.trim().trim_end_matches('/');
479 (!trimmed.is_empty()).then(|| trimmed.to_owned())
480}
481
482fn clean_vec(values: Option<&[String]>) -> Option<Vec<String>> {
483 let cleaned: Vec<String> = values
484 .unwrap_or_default()
485 .iter()
486 .filter_map(|value| clean_string(Some(value.as_str())))
487 .collect();
488 (!cleaned.is_empty()).then_some(cleaned)
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[derive(Debug)]
496 struct MockSearchProvider;
497
498 impl WebSearchProvider for MockSearchProvider {
499 fn search<'a>(
500 &'a self,
501 args: &'a WebSearchArguments,
502 _config: &'a WebSearchToolParam,
503 ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
504 Box::pin(async move {
505 Ok(WebSearchProviderResponse {
506 query: args.query.trim().to_owned(),
507 results: serde_json::json!({
508 "web": [
509 {
510 "url": "https://example.com/potato",
511 "title": "Potato"
512 }
513 ],
514 "news": []
515 }),
516 metadata: serde_json::json!({"provider": "mock"}),
517 })
518 })
519 }
520 }
521
522 #[tokio::test]
523 async fn web_search_handler_delegates_to_provider() {
524 let handler = WebSearchHandler::with_provider(Arc::new(MockSearchProvider));
525 let output = handler
526 .execute(
527 "call_search",
528 "web_search",
529 r#"{"query":" potato "}"#,
530 &serde_json::json!({"type": "web_search_preview"}),
531 )
532 .await
533 .unwrap();
534 let body: Value = serde_json::from_str(&output.output).unwrap();
535 assert_eq!(output.call_id, "call_search");
536 assert_eq!(body["query"], "potato");
537 assert_eq!(body["metadata"]["provider"], "mock");
538 assert_eq!(body["results"]["web"][0]["url"], "https://example.com/potato");
539 }
540}