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