返回 CodeWhale
web_search.rs
根目录 / crates / tui / src / tools / web_search.rs
1 //! Bounded provider-native/configured web search with explicit fallback receipts.
2 //! Adapters include Firecrawl, Tavily, Bocha, Metaso, SearXNG, Baidu,
3 //! Volcengine, Sofya, and Serply; browsing remains a separate `web.run` workflow.
4 //! `[search]` example:
5 //! provider = "firecrawl" # keyless on Firecrawl Cloud; optional api_key
6 //! base_url = `"https://search.example/"` # DDG-compatible URL or SearXNG instance
7
8 use super::spec::{
9 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
10 };
11 use crate::config::{SearchProvider, tavily_env_key, tavily_key_from};
12 use crate::network_policy::{Decision, NetworkPolicyDecider};
13 use async_trait::async_trait;
14 use regex::Regex;
15 use serde::Serialize;
16 use serde_json::{Value, json};
17 use std::sync::OnceLock;
18 use std::time::{Duration, Instant};
19
20 use super::web::backend::SearchBackendChain;
21 use super::web::cache;
22 use super::web::contract::{
23 BackendId, BackendSearch, DEFAULT_SEARCH_RESULTS, DEFAULT_SEARCH_TIMEOUT_MS, DegradedReason,
24 HonoredQueryCapabilities, MAX_SEARCH_RESULTS, MAX_SEARCH_TIMEOUT_MS, QueryKnob, Recency,
25 SearchQuery, SearchReceipt, SearchResponse, SearchResult,
26 };
27 use super::web::scrape::{
28 BROWSER_USER_AGENT as USER_AGENT, ScrapedSearchResult, is_duckduckgo_challenge,
29 parse_bing_results as scrape_bing_results,
30 parse_duckduckgo_results as scrape_duckduckgo_results,
31 };
32
33 const DUCKDUCKGO_ENDPOINT: &str = "https://html.duckduckgo.com/html/";
34 const BING_HOST: &str = "www.bing.com";
35 const BING_ENDPOINT: &str = "https://www.bing.com/search";
36 const FIRECRAWL_ENDPOINT: &str = "https://api.firecrawl.dev/v2/search";
37 const TAVILY_ENDPOINT: &str = "https://api.tavily.com/search";
38 const BOCHA_ENDPOINT: &str = "https://api.bochaai.com/v1/web-search";
39 const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1";
40 const BAIDU_ENDPOINT: &str = "https://qianfan.baidubce.com/v2/ai_search/web_search";
41 const VOLCENGINE_RESPONSES_ENDPOINT: &str = "https://ark.cn-beijing.volces.com/api/v3/responses";
42 const SOFYA_ENDPOINT: &str = "https://sofya.co/v1/search";
43 const SERPLY_ENDPOINT: &str = "https://api.serply.io/v1/search";
44 const ERROR_BODY_PREVIEW_BYTES: usize = 512;
45 const PROVIDER_NATIVE_MIN_TIMEOUT_MS: u64 = 45_000;
46 const KIMI_K3_FORMULA_MIN_TIMEOUT_MS: u64 = 180_000;
47 const VOLCENGINE_MIN_TIMEOUT_MS: u64 = 90_000;
48
49 /// Credential-free endpoint selected for an explicit doctor reachability
50 /// probe. The ordinary search request builders remain the source of truth for
51 /// provider endpoints; doctor borrows those endpoints without constructing a
52 /// query or reading an API key.
53 #[derive(Debug, Clone, PartialEq, Eq)]
54 pub(crate) struct SearchProbeTarget {
55 pub(crate) url: reqwest::Url,
56 pub(crate) host: String,
57 }
58
59 /// Safe configuration failures for a search reachability probe.
60 ///
61 /// These variants deliberately carry no configured URL: userinfo, paths, and
62 /// query strings may contain credentials and must never be echoed by doctor.
63 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
64 pub(crate) enum SearchProbeTargetError {
65 Missing,
66 Unsupported,
67 Invalid,
68 }
69
70 /// Resolve the configured provider's transport endpoint for doctor.
71 ///
72 /// Built-in endpoints keep their known request path. User-configured
73 /// DuckDuckGo-compatible and SearXNG URLs are reduced to their HTTP(S)
74 /// authority so the probe cannot transmit userinfo, path/query credentials,
75 /// or a real search query.
76 pub(crate) fn search_probe_target(
77 provider: SearchProvider,
78 base_url: Option<&str>,
79 ) -> Result<SearchProbeTarget, SearchProbeTargetError> {
80 let configured_base_url = configured_search_base_url(base_url);
81 if configured_base_url.is_some()
82 && !matches!(
83 provider,
84 SearchProvider::DuckDuckGo | SearchProvider::Searxng
85 )
86 {
87 return Err(SearchProbeTargetError::Unsupported);
88 }
89
90 let (raw, configured) = match provider {
91 SearchProvider::Bing => (BING_ENDPOINT, false),
92 SearchProvider::DuckDuckGo => (
93 configured_base_url.unwrap_or(DUCKDUCKGO_ENDPOINT),
94 configured_base_url.is_some(),
95 ),
96 SearchProvider::Firecrawl => (FIRECRAWL_ENDPOINT, false),
97 SearchProvider::Tavily => (TAVILY_ENDPOINT, false),
98 SearchProvider::Bocha => (BOCHA_ENDPOINT, false),
99 SearchProvider::Metaso => (METASO_ENDPOINT, false),
100 SearchProvider::Searxng => (
101 configured_base_url.ok_or(SearchProbeTargetError::Missing)?,
102 true,
103 ),
104 SearchProvider::Baidu => (BAIDU_ENDPOINT, false),
105 SearchProvider::Volcengine => (VOLCENGINE_RESPONSES_ENDPOINT, false),
106 SearchProvider::Sofya => (SOFYA_ENDPOINT, false),
107 SearchProvider::Serply => (SERPLY_ENDPOINT, false),
108 };
109
110 let mut url = reqwest::Url::parse(raw).map_err(|_| SearchProbeTargetError::Invalid)?;
111 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
112 return Err(SearchProbeTargetError::Invalid);
113 }
114
115 url.set_fragment(None);
116 url.set_query(None);
117 if configured {
118 url.set_username("")
119 .map_err(|_| SearchProbeTargetError::Invalid)?;
120 url.set_password(None)
121 .map_err(|_| SearchProbeTargetError::Invalid)?;
122 url.set_path("/");
123 }
124 let host = url
125 .host_str()
126 .ok_or(SearchProbeTargetError::Invalid)?
127 .to_string();
128
129 Ok(SearchProbeTarget { url, host })
130 }
131
132 /// Returns `Ok(())` if the policy allows the call, or a `ToolError` otherwise.
133 /// Falls through silently when no policy is attached (back-compat).
134 pub(crate) fn check_policy(
135 decider: Option<&NetworkPolicyDecider>,
136 host: &str,
137 ) -> Result<(), ToolError> {
138 let Some(decider) = decider else {
139 return Ok(());
140 };
141 match decider.evaluate(host, "web_search") {
142 Decision::Allow => Ok(()),
143 Decision::Deny => Err(ToolError::permission_denied(format!(
144 "web search to '{host}' blocked by network policy"
145 ))),
146 Decision::Prompt => Err(ToolError::permission_denied(format!(
147 "web search to '{host}' requires approval; \
148 re-run after `/network allow {host}` or set network.default = \"allow\" in config"
149 ))),
150 }
151 }
152
153 // Cached regex for secret redaction in error bodies
154 static BEARER_TOKEN_RE: OnceLock<Regex> = OnceLock::new();
155
156 fn get_bearer_token_re() -> &'static Regex {
157 BEARER_TOKEN_RE.get_or_init(|| {
158 Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
159 .expect("bearer token regex pattern is valid")
160 })
161 }
162
163 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164 struct WebSearchEntry {
165 title: String,
166 url: String,
167 snippet: Option<String>,
168 }
169
170 pub struct WebSearchTool;
171
172 #[async_trait]
173 impl ToolSpec for WebSearchTool {
174 fn name(&self) -> &'static str {
175 "web_search"
176 }
177
178 fn model_visible(&self) -> bool {
179 false
180 }
181
182 fn description(&self) -> &'static str {
183 "Search the web and return ranked results with URLs, snippets, session-scoped ref_ids, and an execution receipt. Open a result ref_id with `web.run` when the short summary is not enough; fetch only the few sources needed. When the exact active route reports a documented first-party server-side search tool, it is tried first; otherwise keyless Firecrawl is the default. Configured API backends visibly degrade through DuckDuckGo then Bing when unavailable, and every hop is recorded. Configuration and network-policy errors fail closed. Explicit Bing and private DuckDuckGo-compatible routes do not cross providers. Set `[search] provider = \"firecrawl\" | \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"searxng\" | \"baidu\" | \"volcengine\" | \"sofya\" | \"serply\"` in config.toml. Firecrawl Cloud works keyless with a bounded quota. For a known canonical URL, prefer `fetch_url` directly."
184 }
185
186 fn input_schema(&self) -> Value {
187 json!({
188 "type": "object",
189 "properties": {
190 "query": {
191 "type": "string",
192 "description": "Search query. Compatibility aliases: q, or search_query[0].q."
193 },
194 "q": {
195 "type": "string",
196 "description": "Search query."
197 },
198 "search_query": {
199 "type": "array",
200 "description": "Array form for advanced queries: [{\"q\":\"...\", \"max_results\": 5}]",
201 "items": {
202 "type": "object",
203 "properties": {
204 "q": { "type": "string" },
205 "query": { "type": "string" },
206 "max_results": { "type": "integer" },
207 "recency": {
208 "oneOf": [
209 { "type": "string", "enum": ["day", "week", "month", "year"] },
210 { "type": "integer", "minimum": 1, "maximum": 3650 }
211 ]
212 },
213 "domains": { "type": "array", "items": { "type": "string" } },
214 "locale": { "type": "string" }
215 }
216 }
217 },
218 "max_results": {
219 "type": "integer",
220 "description": "Maximum number of results to return (default: 5, max: 10)"
221 },
222 "timeout_ms": {
223 "type": "integer",
224 "description": "Configured/local search timeout in milliseconds (default: 15000, max: 60000). Model-backed provider-native search has a separate bounded minimum before fallback."
225 },
226 "recency": {
227 "oneOf": [
228 { "type": "string", "enum": ["day", "week", "month", "year"] },
229 { "type": "integer", "minimum": 1, "maximum": 3650 }
230 ],
231 "description": "Requested freshness window. Unsupported backends report it as degraded instead of silently ignoring it."
232 },
233 "domains": {
234 "type": "array",
235 "items": { "type": "string" },
236 "description": "Restrict returned results to these domains. Backends without native support report post-filtering."
237 },
238 "locale": {
239 "type": "string",
240 "description": "Requested result locale. Unsupported backends report it as degraded."
241 }
242 }
243 })
244 }
245
246 fn capabilities(&self) -> Vec<ToolCapability> {
247 vec![ToolCapability::ReadOnly, ToolCapability::Network]
248 }
249
250 fn approval_requirement(&self) -> ApprovalRequirement {
251 // Read-only HTTP can still disclose local data through a URL or query.
252 // Host allowlisting controls reachability, not approval of this payload.
253 ApprovalRequirement::Required
254 }
255
256 fn supports_parallel(&self) -> bool {
257 true
258 }
259
260 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
261 let query = search_query_from_input(&input)?;
262 let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_SEARCH_TIMEOUT_MS)?
263 .min(MAX_SEARCH_TIMEOUT_MS);
264 let response = execute_search(query, timeout_ms, context).await?;
265 ToolResult::json(&response).map_err(|error| ToolError::execution_failed(error.to_string()))
266 }
267 }
268
269 impl WebSearchTool {
270 async fn run_firecrawl_search(
271 &self,
272 query: &str,
273 max_results: usize,
274 timeout_ms: u64,
275 context: &ToolContext,
276 ) -> Result<(Vec<WebSearchEntry>, String), ToolError> {
277 let env_key = std::env::var("FIRECRAWL_API_KEY").ok();
278 self.run_firecrawl_search_at(
279 FIRECRAWL_ENDPOINT,
280 query,
281 max_results,
282 timeout_ms,
283 context.search_api_key.as_deref().or(env_key.as_deref()),
284 )
285 .await
286 }
287
288 async fn run_firecrawl_search_at(
289 &self,
290 endpoint: &str,
291 query: &str,
292 max_results: usize,
293 timeout_ms: u64,
294 api_key: Option<&str>,
295 ) -> Result<(Vec<WebSearchEntry>, String), ToolError> {
296 let client = crate::tls::reqwest_client_builder()
297 .timeout(Duration::from_millis(timeout_ms))
298 .user_agent(USER_AGENT)
299 .build()
300 .map_err(|e| {
301 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
302 })?;
303 let api_key = api_key.map(str::trim).filter(|key| !key.is_empty());
304 let payload = json!({
305 "query": query,
306 "limit": max_results,
307 "sources": [{"type": "web"}],
308 });
309 let mut request = client.post(endpoint).json(&payload);
310 if let Some(key) = api_key {
311 request = request.bearer_auth(key);
312 }
313 let response = request.send().await.map_err(|e| {
314 ToolError::execution_failed(format!("Firecrawl search request failed: {e}"))
315 })?;
316 let status = response.status();
317 let body = response.text().await.map_err(|e| {
318 ToolError::execution_failed(format!("Failed to read Firecrawl response: {e}"))
319 })?;
320 if !status.is_success() {
321 let message = match status.as_u16() {
322 401 | 403 if api_key.is_none() => "Firecrawl rejected keyless search; set `[search] api_key` or FIRECRAWL_API_KEY".to_string(),
323 401 | 403 => "Firecrawl authentication was rejected; check `[search] api_key` or FIRECRAWL_API_KEY".to_string(),
324 429 if api_key.is_none() => "Firecrawl keyless quota is exhausted; retry later or set `[search] api_key` / FIRECRAWL_API_KEY".to_string(),
325 429 => "Firecrawl quota is exhausted; retry later or check the configured account limits".to_string(),
326 code => format!("Firecrawl search failed: HTTP {code} — {}", truncate_error_body(&body)),
327 };
328 return Err(ToolError::execution_failed(message));
329 }
330 let parsed: Value = serde_json::from_str(&body).map_err(|e| {
331 ToolError::execution_failed(format!("Failed to parse Firecrawl response: {e}"))
332 })?;
333 if parsed.get("success").and_then(Value::as_bool) == Some(false) {
334 let detail = first_non_empty_string(&parsed, &["error", "message"])
335 .unwrap_or_else(|| "unknown API error".to_string());
336 return Err(ToolError::execution_failed(format!(
337 "Firecrawl search failed: {detail}"
338 )));
339 }
340 let mode = if api_key.is_some() {
341 "authenticated"
342 } else {
343 "keyless"
344 };
345 Ok((
346 parse_firecrawl_results(&parsed, max_results),
347 format!("Firecrawl {mode}"),
348 ))
349 }
350
351 /// Search a configured SearXNG JSON API; no public instance is assumed.
352 async fn run_searxng_search(
353 &self,
354 query: &str,
355 max_results: usize,
356 timeout_ms: u64,
357 context: &ToolContext,
358 ) -> Result<(Vec<WebSearchEntry>, String), ToolError> {
359 let (url, host) = searxng_search_url(context.search_base_url.as_deref(), query)?;
360 check_policy(context.network_policy.as_ref(), &host)?;
361
362 let client = crate::tls::reqwest_client_builder()
363 .timeout(Duration::from_millis(timeout_ms))
364 .user_agent(USER_AGENT)
365 .build()
366 .map_err(|e| {
367 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
368 })?;
369
370 let resp = client
371 .get(&url)
372 .header("Accept", "application/json")
373 .send()
374 .await
375 .map_err(|e| {
376 ToolError::execution_failed(format!("SearXNG search request to {host} failed: {e}"))
377 })?;
378
379 let status = resp.status();
380 let body = resp.text().await.map_err(|e| {
381 ToolError::execution_failed(format!("Failed to read SearXNG response from {host}: {e}"))
382 })?;
383
384 if !status.is_success() {
385 let truncated = truncate_error_body(&body);
386 let msg = match status.as_u16() {
387 403 => format!(
388 "SearXNG search failed: HTTP 403 from {host}. Check that JSON output is enabled and this instance permits API access. {truncated}"
389 ),
390 429 => format!(
391 "SearXNG search failed: HTTP 429 from {host}. The configured instance is rate-limiting requests; use a trusted/self-hosted instance or retry later. {truncated}"
392 ),
393 code => format!("SearXNG search failed: HTTP {code} from {host}. {truncated}"),
394 };
395 return Err(ToolError::execution_failed(msg));
396 }
397
398 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
399 ToolError::execution_failed(format!(
400 "Failed to parse SearXNG JSON response from {host}: {e}. Ensure the instance supports format=json and JSON output is enabled."
401 ))
402 })?;
403
404 Ok((parse_searxng_results(&parsed, max_results), host))
405 }
406
407 /// Search via Tavily AI Search API (<https://tavily.com>).
408 async fn run_tavily_search(
409 &self,
410 query: &str,
411 max_results: usize,
412 timeout_ms: u64,
413 context: &ToolContext,
414 ) -> Result<Vec<WebSearchEntry>, ToolError> {
415 let api_key = tavily_key_from(context.search_api_key.as_deref())
416 .or_else(|| {
417 // An explicit `provider = "tavily"` still accepts any
418 // non-empty generic key, so a non-`tvly-` pin keeps working.
419 // Reaching this hop at all means Tavily was the resolved
420 // provider (pinned, or selected by a `tvly-` signal), so the
421 // generic fallback is never a Firecrawl/sentinel key.
422 context
423 .search_api_key
424 .as_deref()
425 .map(str::trim)
426 .filter(|value| !value.is_empty())
427 .map(str::to_string)
428 })
429 .ok_or_else(|| {
430 ToolError::execution_failed(
431 "Tavily search requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml or the `TAVILY_API_KEY` env var.",
432 )
433 })?;
434
435 let client = crate::tls::reqwest_client_builder()
436 .timeout(Duration::from_millis(timeout_ms))
437 .build()
438 .map_err(|e| {
439 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
440 })?;
441
442 let payload = json!({
443 "api_key": api_key, // noqa: api-key-in-body
444 "query": query,
445 "search_depth": "basic",
446 "max_results": max_results,
447 });
448
449 let resp = client
450 .post(TAVILY_ENDPOINT)
451 .header("Content-Type", "application/json")
452 .json(&payload)
453 .send()
454 .await
455 .map_err(|e| {
456 ToolError::execution_failed(format!("Tavily search request failed: {e}"))
457 })?;
458
459 let status = resp.status();
460 let body = resp.text().await.map_err(|e| {
461 ToolError::execution_failed(format!("Failed to read Tavily response: {e}"))
462 })?;
463
464 if !status.is_success() {
465 let truncated = truncate_error_body(&body);
466 return Err(ToolError::execution_failed(format!(
467 "Tavily search failed: HTTP {} — {truncated}",
468 status.as_u16()
469 )));
470 }
471
472 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
473 ToolError::execution_failed(format!("Failed to parse Tavily response: {e}"))
474 })?;
475
476 Ok(parse_tavily_results(&parsed, max_results))
477 }
478
479 /// Search Sofya; it returns extracted content and accepts `SOFYA_API_KEY`.
480 async fn run_sofya_search(
481 &self,
482 query: &str,
483 max_results: usize,
484 timeout_ms: u64,
485 context: &ToolContext,
486 ) -> Result<Vec<WebSearchEntry>, ToolError> {
487 let env_key = std::env::var("SOFYA_API_KEY").ok();
488 let api_key = context
489 .search_api_key
490 .as_deref()
491 .or(env_key.as_deref())
492 .ok_or_else(|| {
493 ToolError::execution_failed(
494 "Sofya search requires an API key. Set `[search] api_key = \"ay_live_...\"` in config.toml or the SOFYA_API_KEY env var.",
495 )
496 })?;
497
498 let client = crate::tls::reqwest_client_builder()
499 .timeout(Duration::from_millis(timeout_ms))
500 .build()
501 .map_err(|e| {
502 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
503 })?;
504
505 let payload = json!({
506 "query": query,
507 "max_results": max_results,
508 });
509
510 let resp = client
511 .post(SOFYA_ENDPOINT)
512 .header("Content-Type", "application/json")
513 .bearer_auth(api_key)
514 .json(&payload)
515 .send()
516 .await
517 .map_err(|e| {
518 ToolError::execution_failed(format!("Sofya search request failed: {e}"))
519 })?;
520
521 let status = resp.status();
522 let body = resp.text().await.map_err(|e| {
523 ToolError::execution_failed(format!("Failed to read Sofya response: {e}"))
524 })?;
525
526 if !status.is_success() {
527 let truncated = truncate_error_body(&body);
528 return Err(ToolError::execution_failed(format!(
529 "Sofya search failed: HTTP {} — {truncated}",
530 status.as_u16()
531 )));
532 }
533
534 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
535 ToolError::execution_failed(format!("Failed to parse Sofya response: {e}"))
536 })?;
537
538 Ok(parse_sofya_results(&parsed, max_results))
539 }
540
541 /// Search Serply (<https://serply.io>); it returns Google organic results and
542 /// accepts `SERPLY_API_KEY`.
543 async fn run_serply_search(
544 &self,
545 query: &str,
546 max_results: usize,
547 timeout_ms: u64,
548 context: &ToolContext,
549 ) -> Result<Vec<WebSearchEntry>, ToolError> {
550 let env_key = std::env::var("SERPLY_API_KEY").ok();
551 let api_key = context
552 .search_api_key
553 .as_deref()
554 .or(env_key.as_deref())
555 .ok_or_else(|| {
556 ToolError::invalid_input(
557 "Serply search requires an API key. Set `[search] api_key` in config.toml or the SERPLY_API_KEY env var.",
558 )
559 })?;
560
561 let client = crate::tls::reqwest_client_builder()
562 .timeout(Duration::from_millis(timeout_ms))
563 .build()
564 .map_err(|e| {
565 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
566 })?;
567
568 let resp = client
569 .get(serply_search_url(query, max_results)?)
570 .header("X-Api-Key", api_key)
571 .header("Accept", "application/json")
572 .send()
573 .await
574 .map_err(|e| {
575 ToolError::execution_failed(format!("Serply search request failed: {e}"))
576 })?;
577
578 let status = resp.status();
579 let body = resp.text().await.map_err(|e| {
580 ToolError::execution_failed(format!("Failed to read Serply response: {e}"))
581 })?;
582
583 if !status.is_success() {
584 let truncated = truncate_error_body(&body);
585 return Err(ToolError::execution_failed(format!(
586 "Serply search failed: HTTP {}: {truncated}",
587 status.as_u16()
588 )));
589 }
590
591 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
592 ToolError::execution_failed(format!("Failed to parse Serply response: {e}"))
593 })?;
594
595 Ok(parse_serply_results(&parsed, max_results))
596 }
597
598 /// Search via Bocha AI Search API (<https://bochaai.com>).
599 async fn run_bocha_search(
600 &self,
601 query: &str,
602 max_results: usize,
603 timeout_ms: u64,
604 context: &ToolContext,
605 ) -> Result<Vec<WebSearchEntry>, ToolError> {
606 let api_key = context
607 .search_api_key
608 .as_deref()
609 .ok_or_else(|| {
610 ToolError::execution_failed(
611 "Bocha search requires an API key. Set `[search] api_key = \"sk-...\"` in config.toml.",
612 )
613 })?;
614
615 let client = crate::tls::reqwest_client_builder()
616 .timeout(Duration::from_millis(timeout_ms))
617 .build()
618 .map_err(|e| {
619 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
620 })?;
621
622 let payload = json!({
623 "query": query,
624 "freshness": "noLimit",
625 "count": max_results,
626 });
627
628 let resp = client
629 .post(BOCHA_ENDPOINT)
630 .header("Content-Type", "application/json")
631 .header("Authorization", format!("Bearer {api_key}"))
632 .json(&payload)
633 .send()
634 .await
635 .map_err(|e| {
636 ToolError::execution_failed(format!("Bocha search request failed: {e}"))
637 })?;
638
639 let status = resp.status();
640 let body = resp.text().await.map_err(|e| {
641 ToolError::execution_failed(format!("Failed to read Bocha response: {e}"))
642 })?;
643
644 if !status.is_success() {
645 let truncated = truncate_error_body(&body);
646 return Err(ToolError::execution_failed(format!(
647 "Bocha search failed: HTTP {} — {truncated}",
648 status.as_u16()
649 )));
650 }
651
652 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
653 ToolError::execution_failed(format!("Failed to parse Bocha response: {e}"))
654 })?;
655
656 if let Some(error) = bocha_error_message(&parsed) {
657 return Err(ToolError::execution_failed(error));
658 }
659
660 Ok(parse_bocha_results(&parsed, max_results))
661 }
662
663 /// Search via Metaso AI Search API (<https://metaso.cn>). Falls back to
664 /// `METASO_API_KEY` when no config key is set.
665 async fn run_metaso_search(
666 &self,
667 query: &str,
668 max_results: usize,
669 timeout_ms: u64,
670 context: &ToolContext,
671 ) -> Result<Vec<WebSearchEntry>, ToolError> {
672 let env_key = std::env::var("METASO_API_KEY").ok();
673 let api_key = context
674 .search_api_key
675 .as_deref()
676 .or(env_key.as_deref())
677 .ok_or_else(|| {
678 ToolError::execution_failed(
679 "Metaso search requires an API key. Set `METASO_API_KEY` or `[search] api_key` in config.toml.",
680 )
681 })?;
682
683 let client = crate::tls::reqwest_client_builder()
684 .timeout(Duration::from_millis(timeout_ms))
685 .build()
686 .map_err(|e| {
687 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
688 })?;
689
690 let size = max_results.clamp(1, 100);
691 let payload = json!({
692 "q": query,
693 "scope": "webpage",
694 "size": size,
695 });
696
697 let resp = client
698 .post(format!("{METASO_ENDPOINT}/search"))
699 .header("Content-Type", "application/json")
700 .header("Authorization", format!("Bearer {api_key}"))
701 .json(&payload)
702 .send()
703 .await
704 .map_err(|e| {
705 ToolError::execution_failed(format!("Metaso search request failed: {e}"))
706 })?;
707
708 let status = resp.status();
709 let body = resp.text().await.map_err(|e| {
710 ToolError::execution_failed(format!("Failed to read Metaso response: {e}"))
711 })?;
712
713 if !status.is_success() {
714 let msg = match status.as_u16() {
715 401 | 403 => "Metaso API key rejected — check METASO_API_KEY or set `[search] api_key` in config.toml, or get one at https://metaso.cn/search-api/playground".to_string(),
716 429 => "Metaso rate-limited — wait and retry, or get your own API key at https://metaso.cn/search-api/playground".to_string(),
717 _ => {
718 let truncated = truncate_error_body(&body);
719 format!("Metaso server error (HTTP {status}) — {truncated}")
720 }
721 };
722 return Err(ToolError::execution_failed(msg));
723 }
724
725 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
726 ToolError::execution_failed(format!("Failed to parse Metaso response: {e}"))
727 })?;
728
729 // Check business-logic error codes in the response body.
730 if let Some(code) = parsed.get("code").and_then(|v| v.as_i64())
731 && code != 0
732 {
733 let msg = parsed
734 .get("message")
735 .and_then(|v| v.as_str())
736 .unwrap_or("unknown error");
737 return Err(ToolError::execution_failed(match code {
738 3003 => "Metaso: daily search limit reached — set METASO_API_KEY or get one at https://metaso.cn/search-api/playground".to_string(),
739 2005 => "Metaso API key rejected — check METASO_API_KEY or set `[search] api_key` in config.toml".to_string(),
740 _ => format!("Metaso API error (code {code}: {msg})"),
741 }));
742 }
743
744 Ok(parse_metaso_results(&parsed, size))
745 }
746
747 /// Search via Baidu AI Search API (<https://qianfan.baidubce.com>).
748 async fn run_baidu_search(
749 &self,
750 query: &str,
751 max_results: usize,
752 timeout_ms: u64,
753 context: &ToolContext,
754 ) -> Result<Vec<WebSearchEntry>, ToolError> {
755 let env_key = std::env::var("BAIDU_SEARCH_API_KEY").ok();
756 let api_key = context
757 .search_api_key
758 .as_deref()
759 .or(env_key.as_deref())
760 .ok_or_else(|| {
761 ToolError::execution_failed(
762 "Baidu search requires an API key. Set `BAIDU_SEARCH_API_KEY` or `[search] api_key` in config.toml.",
763 )
764 })?;
765
766 let client = crate::tls::reqwest_client_builder()
767 .timeout(Duration::from_millis(timeout_ms))
768 .build()
769 .map_err(|e| {
770 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
771 })?;
772
773 let payload = baidu_search_payload(query, max_results);
774
775 // Baidu's AI Search endpoint accepts conversational messages rather
776 // than an index-only query. Treat the entire request/response decode
777 // as model-backed for attached-run ownership; a false negative here
778 // could overlap Runtime Chat, while the conservative read lease only
779 // serializes work that already belongs to the same interactive run.
780 let _inference = acquire_model_backed_search_inference_participant().await;
781
782 let resp = client
783 .post(BAIDU_ENDPOINT)
784 .header("Authorization", format!("Bearer {api_key}"))
785 .json(&payload)
786 .send()
787 .await
788 .map_err(|e| {
789 ToolError::execution_failed(format!("Baidu search request failed: {e}"))
790 })?;
791
792 let status = resp.status();
793 let body = resp.text().await.map_err(|e| {
794 ToolError::execution_failed(format!("Failed to read Baidu response: {e}"))
795 })?;
796
797 if !status.is_success() {
798 let msg = match status.as_u16() {
799 401 | 403 => "Baidu search API key rejected — check BAIDU_SEARCH_API_KEY or `[search] api_key` in config.toml".to_string(),
800 429 => "Baidu search rate-limited — wait and retry, or check your Baidu AI Search quota".to_string(),
801 _ => {
802 let truncated = truncate_error_body(&body);
803 format!("Baidu search failed: HTTP {} — {truncated}", status.as_u16())
804 }
805 };
806 return Err(ToolError::execution_failed(msg));
807 }
808
809 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
810 ToolError::execution_failed(format!("Failed to parse Baidu response: {e}"))
811 })?;
812
813 if let Some(error) = baidu_error_message(&parsed) {
814 return Err(ToolError::execution_failed(error));
815 }
816
817 Ok(parse_baidu_results(&parsed, max_results))
818 }
819
820 /// Search via Volcengine Ark; it needs a 90s floor and retries transport failures.
821 async fn run_volcengine_search(
822 &self,
823 query: &str,
824 max_results: usize,
825 timeout_ms: u64,
826 context: &ToolContext,
827 ) -> Result<Vec<WebSearchEntry>, ToolError> {
828 let volc_key = std::env::var("VOLCENGINE_API_KEY").ok();
829 let volc_ark_key = std::env::var("VOLCENGINE_ARK_API_KEY").ok();
830 let ark_key = std::env::var("ARK_API_KEY").ok();
831 let api_key = context
832 .search_api_key
833 .as_deref()
834 .or(volc_key.as_deref())
835 .or(volc_ark_key.as_deref())
836 .or(ark_key.as_deref())
837 .ok_or_else(|| {
838 ToolError::execution_failed(
839 "Volcengine search requires an API key. Set `[search] api_key`, \
840 or VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY env var.",
841 )
842 })?;
843
844 let effective_timeout = timeout_ms.max(90_000);
845
846 let client = crate::tls::reqwest_client_builder()
847 .connect_timeout(Duration::from_secs(15))
848 .timeout(Duration::from_millis(effective_timeout))
849 .tcp_keepalive(Some(Duration::from_secs(30)))
850 .http2_keep_alive_interval(Some(Duration::from_secs(15)))
851 .http2_keep_alive_timeout(Duration::from_secs(20))
852 .user_agent(USER_AGENT)
853 .build()
854 .map_err(|e| {
855 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
856 })?;
857
858 let payload = volcengine_search_payload(query, max_results);
859
860 // Unlike the ordinary index-search backends, Volcengine's Responses
861 // endpoint runs a named model and returns model-generated text. Keep
862 // that provider lifecycle inside the attached CWC run's shared read
863 // ownership through retries and response decoding, so an isolated
864 // Runtime Chat turn cannot be projected alongside it.
865 let _inference = acquire_model_backed_search_inference_participant().await;
866
867 let mut last_err: Option<ToolError> = None;
868 for attempt in 0..3 {
869 if attempt > 0 {
870 tokio::time::sleep(Duration::from_millis(1000 * (1 << (attempt - 1)))).await;
871 }
872
873 match client
874 .post(VOLCENGINE_RESPONSES_ENDPOINT)
875 .header("Authorization", format!("Bearer {api_key}"))
876 .json(&payload)
877 .send()
878 .await
879 {
880 Ok(resp) => {
881 let status = resp.status();
882 let body = resp.text().await.map_err(|e| {
883 ToolError::execution_failed(format!(
884 "Failed to read Volcengine response: {e}"
885 ))
886 })?;
887
888 if !status.is_success() {
889 let msg = match status.as_u16() {
890 401 | 403 => "Volcengine API key rejected — check `[search] api_key` in config.toml or VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY".to_string(),
891 429 => "Volcengine API rate-limited — wait and retry, or check your quota".to_string(),
892 _ => {
893 let truncated = truncate_error_body(&body);
894 format!("Volcengine search failed: HTTP {} — {truncated}", status.as_u16())
895 }
896 };
897 return Err(ToolError::execution_failed(msg));
898 }
899
900 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
901 ToolError::execution_failed(format!(
902 "Failed to parse Volcengine response: {e}"
903 ))
904 })?;
905
906 if let Some(error) = volcengine_error_message(&parsed) {
907 return Err(ToolError::execution_failed(error));
908 }
909
910 let response_text = volcengine_extract_text(&parsed).ok_or_else(|| {
911 ToolError::execution_failed("Volcengine response contains no output text")
912 })?;
913
914 return Ok(parse_volcengine_results(&response_text, max_results));
915 }
916 Err(e) => {
917 let is_transient = e.is_timeout() || e.is_connect();
918 if !is_transient || attempt == 2 {
919 return Err(ToolError::execution_failed(format!(
920 "Volcengine search request failed: {e}"
921 )));
922 }
923 last_err = Some(ToolError::execution_failed(format!(
924 "Volcengine search request failed (attempt {}/3): {e}",
925 attempt + 1
926 )));
927 }
928 }
929 }
930
931 // Unreachable — the final iteration always returns above.
932 Err(last_err.unwrap_or_else(|| {
933 ToolError::execution_failed("Volcengine search: unexpected retry exit")
934 }))
935 }
936 }
937
938 pub(crate) async fn execute_search(
939 query: SearchQuery,
940 timeout_ms: u64,
941 context: &ToolContext,
942 ) -> Result<SearchResponse, ToolError> {
943 if configured_search_base_url(context.search_base_url.as_deref()).is_some()
944 && !matches!(
945 context.search_provider,
946 SearchProvider::DuckDuckGo | SearchProvider::Searxng
947 )
948 {
949 return Err(ToolError::invalid_input(format!(
950 "[search].base_url is only supported with provider = \"duckduckgo\" or \"searxng\"; current provider is \"{}\"",
951 context.search_provider.as_str()
952 )));
953 }
954
955 let chain = SearchBackendChain::from_context(context);
956 let initial_backend = chain.initial_backend();
957 if initial_backend != BackendId::ProviderNative {
958 debug_assert_eq!(initial_backend.as_str(), context.search_provider.as_str());
959 preflight_search_provider(context)?;
960 }
961 let cache_scope = if initial_backend == BackendId::ProviderNative {
962 context
963 .provider_native_search
964 .as_ref()
965 .map(crate::client::ProviderNativeSearchClient::cache_identity)
966 } else {
967 normalized_search_base_url(context.search_base_url.as_deref())
968 };
969
970 if let Some(mut cached) = cache::get_search(
971 &context.state_namespace,
972 initial_backend,
973 cache_scope.as_deref(),
974 &query,
975 ) {
976 validate_cached_search_policy(&cached, context)?;
977 register_search_citations(&mut cached, context);
978 cached.receipt.cache_hit = true;
979 cached.receipt.latency_ms = 0;
980 return Ok(cached);
981 }
982
983 let started = Instant::now();
984 let requested_timeout = Duration::from_millis(timeout_ms.max(1));
985 let provider_native_timeout_floor = context
986 .provider_native_search
987 .as_ref()
988 .and_then(provider_native_timeout_floor);
989 let (total_timeout, first_attempt_budget, fallback_budget_after_first) = search_timeout_budgets(
990 initial_backend,
991 requested_timeout,
992 provider_native_timeout_floor,
993 );
994 let deadline = started + total_timeout;
995 let chained = chain
996 .search(
997 &query,
998 deadline,
999 first_attempt_budget,
1000 fallback_budget_after_first,
1001 )
1002 .await?;
1003 let mut response =
1004 finalize_search_response(query.clone(), chained.capabilities, chained.raw, started);
1005 register_search_citations(&mut response, context);
1006 cache::insert_search(
1007 &context.state_namespace,
1008 initial_backend,
1009 cache_scope.as_deref(),
1010 &query,
1011 response.clone(),
1012 );
1013 Ok(response)
1014 }
1015
1016 fn search_timeout_budgets(
1017 initial_backend: BackendId,
1018 requested_timeout: Duration,
1019 provider_native_timeout_floor: Option<Duration>,
1020 ) -> (Duration, Option<Duration>, Option<Duration>) {
1021 match initial_backend {
1022 BackendId::Volcengine => {
1023 let provider_budget = Duration::from_millis(VOLCENGINE_MIN_TIMEOUT_MS);
1024 (
1025 provider_budget + requested_timeout,
1026 Some(provider_budget),
1027 None,
1028 )
1029 }
1030 BackendId::ProviderNative => {
1031 // Provider-native search performs a model-backed request. Give it
1032 // a dedicated minimum without donating unused time to the
1033 // configured/local fallback selected by the caller.
1034 let provider_budget = requested_timeout.max(
1035 provider_native_timeout_floor
1036 .unwrap_or(Duration::from_millis(PROVIDER_NATIVE_MIN_TIMEOUT_MS)),
1037 );
1038 (
1039 provider_budget.saturating_add(requested_timeout),
1040 Some(provider_budget),
1041 Some(requested_timeout),
1042 )
1043 }
1044 _ => (requested_timeout, None, None),
1045 }
1046 }
1047
1048 fn provider_native_timeout_floor(
1049 client: &crate::client::ProviderNativeSearchClient,
1050 ) -> Option<Duration> {
1051 crate::config::is_exact_direct_moonshot_k3_route(
1052 client.provider(),
1053 client.base_url(),
1054 client.model(),
1055 )
1056 .then_some(Duration::from_millis(KIMI_K3_FORMULA_MIN_TIMEOUT_MS))
1057 }
1058
1059 fn register_search_citations(response: &mut SearchResponse, context: &ToolContext) {
1060 let mut seen = std::collections::HashSet::new();
1061 response.results.retain_mut(|result| {
1062 let Some(citation) = super::web::citations::register(
1063 &context.state_namespace,
1064 &result.url,
1065 Some(&result.title),
1066 ) else {
1067 return false;
1068 };
1069 result.ref_id = citation.ref_id;
1070 result.url = citation.url;
1071 seen.insert(result.ref_id.clone())
1072 });
1073 if response.count != response.results.len() {
1074 rerank(&mut response.results);
1075 response.count = response.results.len();
1076 response.message = if response.count == 0 {
1077 "No usable web citations found".to_string()
1078 } else {
1079 format!("Found {} result(s)", response.count)
1080 };
1081 }
1082 }
1083
1084 /// Reject misconfiguration before cache lookup or network access.
1085 fn preflight_search_provider(context: &ToolContext) -> Result<(), ToolError> {
1086 let configured_key = context
1087 .search_api_key
1088 .as_deref()
1089 .is_some_and(|key| !key.trim().is_empty());
1090 let env_key = |name: &str| std::env::var_os(name).is_some_and(|value| !value.is_empty());
1091 let not_configured = |message: &str| Err(ToolError::invalid_input(message));
1092
1093 match context.search_provider {
1094 SearchProvider::Tavily if !configured_key && tavily_env_key().is_none() => not_configured(
1095 "Tavily search is not configured: it requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml or the `TAVILY_API_KEY` env var.",
1096 ),
1097 SearchProvider::Bocha if !configured_key => not_configured(
1098 "Bocha search is not configured: it requires an API key. Set `[search] api_key = \"sk-...\"` in config.toml.",
1099 ),
1100 SearchProvider::Metaso if !configured_key && !env_key("METASO_API_KEY") => not_configured(
1101 "Metaso search is not configured: it requires an API key. Set `METASO_API_KEY` or `[search] api_key` in config.toml.",
1102 ),
1103 SearchProvider::Baidu if !configured_key && !env_key("BAIDU_SEARCH_API_KEY") => {
1104 not_configured(
1105 "Baidu search is not configured: it requires an API key. Set `BAIDU_SEARCH_API_KEY` or `[search] api_key` in config.toml.",
1106 )
1107 }
1108 SearchProvider::Volcengine
1109 if !configured_key
1110 && !env_key("VOLCENGINE_API_KEY")
1111 && !env_key("VOLCENGINE_ARK_API_KEY")
1112 && !env_key("ARK_API_KEY") =>
1113 {
1114 not_configured(
1115 "Volcengine search is not configured: it requires an API key. Set `[search] api_key`, or VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY env var.",
1116 )
1117 }
1118 SearchProvider::Sofya if !configured_key && !env_key("SOFYA_API_KEY") => not_configured(
1119 "Sofya search is not configured: it requires an API key. Set `[search] api_key = \"ay_live_...\"` in config.toml or the SOFYA_API_KEY env var.",
1120 ),
1121 SearchProvider::Serply if !configured_key && !env_key("SERPLY_API_KEY") => not_configured(
1122 "Serply search is not configured: it requires an API key. Set `[search] api_key` in config.toml or the SERPLY_API_KEY env var.",
1123 ),
1124 SearchProvider::Searxng
1125 if configured_search_base_url(context.search_base_url.as_deref()).is_none() =>
1126 {
1127 not_configured(
1128 "SearXNG search requires [search] base_url = \"https://your-searxng.example\"; no public instance is used by default.",
1129 )
1130 }
1131 _ => Ok(()),
1132 }
1133 }
1134
1135 fn normalized_search_base_url(base_url: Option<&str>) -> Option<String> {
1136 let raw = configured_search_base_url(base_url)?;
1137 let Ok(mut url) = reqwest::Url::parse(raw) else {
1138 return Some(raw.to_string());
1139 };
1140 url.set_fragment(None);
1141 Some(url.to_string())
1142 }
1143
1144 fn validate_cached_search_policy(
1145 response: &SearchResponse,
1146 context: &ToolContext,
1147 ) -> Result<(), ToolError> {
1148 let host = response
1149 .receipt
1150 .backend_detail
1151 .as_deref()
1152 .or_else(|| default_backend_host(response.receipt.backend))
1153 .ok_or_else(|| {
1154 ToolError::execution_failed("cached search receipt did not identify its backend host")
1155 })?;
1156 check_policy(context.network_policy.as_ref(), host)
1157 }
1158
1159 const fn default_backend_host(backend: BackendId) -> Option<&'static str> {
1160 match backend {
1161 BackendId::ProviderNative => None,
1162 BackendId::Bing => Some(BING_HOST),
1163 BackendId::DuckDuckGo => Some("html.duckduckgo.com"),
1164 BackendId::Firecrawl => Some("api.firecrawl.dev"),
1165 BackendId::Tavily => Some("api.tavily.com"),
1166 BackendId::Bocha => Some("api.bochaai.com"),
1167 BackendId::Metaso => Some("metaso.cn"),
1168 BackendId::Searxng => None,
1169 BackendId::Baidu => Some("qianfan.baidubce.com"),
1170 BackendId::Volcengine => Some("ark.cn-beijing.volces.com"),
1171 BackendId::Sofya => Some("sofya.co"),
1172 BackendId::Serply => Some("api.serply.io"),
1173 }
1174 }
1175
1176 fn finalize_search_response(
1177 query: SearchQuery,
1178 capabilities: super::web::contract::QueryCapabilities,
1179 mut raw: BackendSearch,
1180 started: Instant,
1181 ) -> SearchResponse {
1182 let mut honored = HonoredQueryCapabilities {
1183 max_results: matches!(
1184 capabilities.max_results,
1185 super::web::contract::CapabilityState::Supported
1186 ),
1187 ..HonoredQueryCapabilities::default()
1188 };
1189
1190 if query.recency.is_some() {
1191 if matches!(
1192 capabilities.recency,
1193 super::web::contract::CapabilityState::Supported
1194 ) {
1195 honored.recency = true;
1196 } else {
1197 raw.degraded.push(DegradedReason::KnobIgnored {
1198 knob: QueryKnob::Recency,
1199 });
1200 }
1201 }
1202 if !query.domains.is_empty() {
1203 // The backend chain applies this before deciding whether an attempt
1204 // produced usable results. Keep finalization defensive for cached or
1205 // directly constructed responses; the helper is idempotent.
1206 apply_domain_constraints(&query, capabilities, &mut raw);
1207 honored.domains = true;
1208 }
1209 if query.locale.is_some() {
1210 if matches!(
1211 capabilities.locale,
1212 super::web::contract::CapabilityState::Supported
1213 ) {
1214 honored.locale = true;
1215 } else {
1216 raw.degraded.push(DegradedReason::KnobIgnored {
1217 knob: QueryKnob::Locale,
1218 });
1219 }
1220 }
1221
1222 raw.results.truncate(usize::from(query.max_results));
1223 rerank(&mut raw.results);
1224 let latency_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
1225 let receipt = SearchReceipt {
1226 backend: raw.backend,
1227 backend_detail: raw.backend_detail,
1228 requested: query.clone(),
1229 capabilities,
1230 honored,
1231 degraded: raw.degraded,
1232 latency_ms,
1233 cache_hit: false,
1234 };
1235 let count = raw.results.len();
1236 let message = match (count, raw.note.as_deref()) {
1237 (0, Some(note)) => format!("No results found. {note}"),
1238 (0, None) => "No results found".to_string(),
1239 (_, Some(note)) => format!("Found {count} result(s). {note}"),
1240 (_, None) => format!("Found {count} result(s)"),
1241 };
1242
1243 SearchResponse {
1244 query: query.query,
1245 source: raw.source,
1246 count,
1247 message,
1248 results: raw.results,
1249 receipt,
1250 }
1251 }
1252
1253 pub(crate) fn apply_domain_constraints(
1254 query: &SearchQuery,
1255 capabilities: super::web::contract::QueryCapabilities,
1256 raw: &mut BackendSearch,
1257 ) {
1258 if query.domains.is_empty() {
1259 return;
1260 }
1261
1262 let before = raw.results.len();
1263 raw.results
1264 .retain(|result| domain_matches(&result.url, &query.domains));
1265 rerank(&mut raw.results);
1266 let provider_honored = matches!(
1267 capabilities.domains,
1268 super::web::contract::CapabilityState::Supported
1269 );
1270 let filtered_any = raw.results.len() != before;
1271 if raw.backend == BackendId::ProviderNative && (!provider_honored || filtered_any) {
1272 // Post-filtering constrains returned citations but cannot prove that a
1273 // provider-generated answer did not rely on a removed source.
1274 raw.note = None;
1275 }
1276 let already_recorded = raw.degraded.iter().any(|reason| {
1277 matches!(
1278 reason,
1279 DegradedReason::PostFiltered {
1280 knob: QueryKnob::Domains
1281 }
1282 )
1283 });
1284 if (!provider_honored || filtered_any) && !already_recorded {
1285 raw.degraded.push(DegradedReason::PostFiltered {
1286 knob: QueryKnob::Domains,
1287 });
1288 }
1289 }
1290
1291 pub(crate) async fn run_backend_search(
1292 provider: SearchProvider,
1293 query: &SearchQuery,
1294 deadline: Instant,
1295 context: &ToolContext,
1296 ) -> Result<BackendSearch, ToolError> {
1297 let timeout_ms = u64::try_from(
1298 deadline
1299 .saturating_duration_since(Instant::now())
1300 .as_millis()
1301 .max(1),
1302 )
1303 .unwrap_or(u64::MAX);
1304 let max_results = usize::from(query.max_results);
1305 let tool = WebSearchTool;
1306 let simple = |backend, entries: Vec<WebSearchEntry>| BackendSearch {
1307 backend,
1308 source: backend.as_str().to_string(),
1309 backend_detail: None,
1310 results: normalize_entries(entries),
1311 degraded: Vec::new(),
1312 note: None,
1313 };
1314
1315 match provider {
1316 SearchProvider::Firecrawl => {
1317 check_policy(context.network_policy.as_ref(), "api.firecrawl.dev")?;
1318 let (results, note) = tool
1319 .run_firecrawl_search(&query.query, max_results, timeout_ms, context)
1320 .await?;
1321 Ok(BackendSearch {
1322 backend: BackendId::Firecrawl,
1323 source: "firecrawl".to_string(),
1324 backend_detail: Some("api.firecrawl.dev".to_string()),
1325 results: normalize_entries(results),
1326 degraded: Vec::new(),
1327 note: Some(note),
1328 })
1329 }
1330 SearchProvider::Tavily => {
1331 check_policy(context.network_policy.as_ref(), "api.tavily.com")?;
1332 Ok(simple(
1333 BackendId::Tavily,
1334 tool.run_tavily_search(&query.query, max_results, timeout_ms, context)
1335 .await?,
1336 ))
1337 }
1338 SearchProvider::Bocha => {
1339 check_policy(context.network_policy.as_ref(), "api.bochaai.com")?;
1340 Ok(simple(
1341 BackendId::Bocha,
1342 tool.run_bocha_search(&query.query, max_results, timeout_ms, context)
1343 .await?,
1344 ))
1345 }
1346 SearchProvider::Metaso => {
1347 check_policy(context.network_policy.as_ref(), "metaso.cn")?;
1348 Ok(simple(
1349 BackendId::Metaso,
1350 tool.run_metaso_search(&query.query, max_results, timeout_ms, context)
1351 .await?,
1352 ))
1353 }
1354 SearchProvider::Searxng => {
1355 let (entries, host) = tool
1356 .run_searxng_search(&query.query, max_results, timeout_ms, context)
1357 .await?;
1358 let note = format!("Backend: searxng at {host}");
1359 Ok(BackendSearch {
1360 backend: BackendId::Searxng,
1361 source: "searxng".to_string(),
1362 backend_detail: Some(host),
1363 results: normalize_entries(entries),
1364 degraded: Vec::new(),
1365 note: Some(note),
1366 })
1367 }
1368 SearchProvider::Baidu => {
1369 check_policy(context.network_policy.as_ref(), "qianfan.baidubce.com")?;
1370 Ok(simple(
1371 BackendId::Baidu,
1372 tool.run_baidu_search(&query.query, max_results, timeout_ms, context)
1373 .await?,
1374 ))
1375 }
1376 SearchProvider::Volcengine => {
1377 check_policy(context.network_policy.as_ref(), "ark.cn-beijing.volces.com")?;
1378 let mut response = simple(
1379 BackendId::Volcengine,
1380 tool.run_volcengine_search(&query.query, max_results, timeout_ms, context)
1381 .await?,
1382 );
1383 response.degraded.push(DegradedReason::SynthesizedResults);
1384 Ok(response)
1385 }
1386 SearchProvider::Sofya => {
1387 check_policy(context.network_policy.as_ref(), "sofya.co")?;
1388 Ok(simple(
1389 BackendId::Sofya,
1390 tool.run_sofya_search(&query.query, max_results, timeout_ms, context)
1391 .await?,
1392 ))
1393 }
1394 SearchProvider::Serply => {
1395 check_policy(context.network_policy.as_ref(), "api.serply.io")?;
1396 Ok(simple(
1397 BackendId::Serply,
1398 tool.run_serply_search(&query.query, max_results, timeout_ms, context)
1399 .await?,
1400 ))
1401 }
1402 SearchProvider::Bing | SearchProvider::DuckDuckGo => {
1403 run_scrape_search(provider, query, timeout_ms, context).await
1404 }
1405 }
1406 }
1407
1408 #[derive(Clone, Copy)]
1409 struct ScrapeEndpoints<'a> {
1410 bing: &'a str,
1411 allow_bing_fallback: Option<bool>,
1412 }
1413
1414 impl Default for ScrapeEndpoints<'static> {
1415 fn default() -> Self {
1416 Self {
1417 bing: BING_ENDPOINT,
1418 allow_bing_fallback: None,
1419 }
1420 }
1421 }
1422
1423 async fn run_scrape_search(
1424 provider: SearchProvider,
1425 query: &SearchQuery,
1426 timeout_ms: u64,
1427 context: &ToolContext,
1428 ) -> Result<BackendSearch, ToolError> {
1429 let fallback_context = (provider == SearchProvider::DuckDuckGo
1430 && context.search_provider != SearchProvider::DuckDuckGo)
1431 .then(|| {
1432 let mut cloned = context.clone();
1433 cloned.search_base_url = None;
1434 cloned
1435 });
1436 let context = fallback_context.as_ref().unwrap_or(context);
1437 run_scrape_search_with_endpoints(
1438 provider,
1439 query,
1440 timeout_ms,
1441 context,
1442 ScrapeEndpoints::default(),
1443 )
1444 .await
1445 }
1446
1447 async fn run_scrape_search_with_endpoints(
1448 provider: SearchProvider,
1449 query: &SearchQuery,
1450 timeout_ms: u64,
1451 context: &ToolContext,
1452 endpoints: ScrapeEndpoints<'_>,
1453 ) -> Result<BackendSearch, ToolError> {
1454 let decider = context.network_policy.as_ref();
1455 let client = crate::tls::reqwest_client_builder()
1456 .timeout(Duration::from_millis(timeout_ms))
1457 .user_agent(USER_AGENT)
1458 .build()
1459 .map_err(|error| {
1460 ToolError::execution_failed(format!("Failed to build HTTP client: {error}"))
1461 })?;
1462 let max_results = usize::from(query.max_results);
1463 let mut degraded = Vec::new();
1464
1465 if provider == SearchProvider::Bing {
1466 check_policy(decider, BING_HOST)?;
1467 let results = run_bing_search(&client, &query.query, max_results, endpoints.bing).await?;
1468 return Ok(BackendSearch {
1469 backend: BackendId::Bing,
1470 source: "bing".to_string(),
1471 backend_detail: None,
1472 results: normalize_entries(results),
1473 degraded,
1474 note: None,
1475 });
1476 }
1477
1478 let (url, duckduckgo_host) =
1479 duckduckgo_search_url(context.search_base_url.as_deref(), &query.query)?;
1480 let allow_bing_fallback = endpoints
1481 .allow_bing_fallback
1482 .unwrap_or_else(|| duckduckgo_allows_bing_fallback(context.search_base_url.as_deref()));
1483 check_policy(decider, &duckduckgo_host)?;
1484 let resp = client
1485 .get(&url)
1486 .header(
1487 "Accept",
1488 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1489 )
1490 .header("Accept-Language", "en-US,en;q=0.5")
1491 .send()
1492 .await
1493 .map_err(|error| {
1494 ToolError::execution_failed(format!("Web search request failed: {error}"))
1495 })?;
1496 let status = resp.status();
1497 let body = resp.text().await.map_err(|error| {
1498 ToolError::execution_failed(format!("Failed to read response: {error}"))
1499 })?;
1500 if !status.is_success() {
1501 return Err(ToolError::execution_failed(format!(
1502 "Web search failed: HTTP {}",
1503 status.as_u16()
1504 )));
1505 }
1506
1507 let results = parse_duckduckgo_results(&body, max_results);
1508 let blocked = is_duckduckgo_challenge(&body);
1509 if !results.is_empty() {
1510 return Ok(BackendSearch {
1511 backend: BackendId::DuckDuckGo,
1512 source: if allow_bing_fallback {
1513 "duckduckgo".to_string()
1514 } else {
1515 duckduckgo_host.clone()
1516 },
1517 backend_detail: (!allow_bing_fallback).then_some(duckduckgo_host),
1518 results: normalize_entries(results),
1519 degraded,
1520 note: None,
1521 });
1522 }
1523 if blocked {
1524 degraded.push(DegradedReason::ChallengeDetected {
1525 backend: BackendId::DuckDuckGo,
1526 });
1527 }
1528 if !allow_bing_fallback {
1529 if blocked {
1530 return Err(ToolError::execution_failed(format!(
1531 "DuckDuckGo-compatible search endpoint at {duckduckgo_host} returned a bot challenge; check the private search service, credentials, or network policy"
1532 )));
1533 }
1534 return Ok(BackendSearch {
1535 backend: BackendId::DuckDuckGo,
1536 source: duckduckgo_host.clone(),
1537 backend_detail: Some(duckduckgo_host),
1538 results: Vec::new(),
1539 degraded,
1540 note: None,
1541 });
1542 }
1543
1544 check_policy(decider, BING_HOST)?;
1545 match run_bing_search(&client, &query.query, max_results, endpoints.bing).await {
1546 Ok(results) if !results.is_empty() => {
1547 degraded.push(DegradedReason::ScrapeFallback {
1548 from: BackendId::DuckDuckGo,
1549 to: BackendId::Bing,
1550 });
1551 Ok(BackendSearch {
1552 backend: BackendId::Bing,
1553 source: "bing".to_string(),
1554 backend_detail: None,
1555 results: normalize_entries(results),
1556 degraded,
1557 note: Some(if blocked {
1558 "DuckDuckGo returned a bot challenge; used Bing fallback".to_string()
1559 } else {
1560 "DuckDuckGo returned no parseable results; used Bing fallback".to_string()
1561 }),
1562 })
1563 }
1564 Ok(_) if blocked => Err(ToolError::execution_failed(
1565 "DuckDuckGo returned a bot challenge and Bing fallback returned no results",
1566 )),
1567 Err(error) if blocked => Err(ToolError::execution_failed(format!(
1568 "DuckDuckGo returned a bot challenge and Bing fallback failed: {error}"
1569 ))),
1570 Ok(_) | Err(_) => Ok(BackendSearch {
1571 backend: BackendId::DuckDuckGo,
1572 source: "duckduckgo".to_string(),
1573 backend_detail: None,
1574 results: Vec::new(),
1575 degraded,
1576 note: None,
1577 }),
1578 }
1579 }
1580
1581 fn normalize_entries(entries: Vec<WebSearchEntry>) -> Vec<SearchResult> {
1582 entries
1583 .into_iter()
1584 .enumerate()
1585 .map(|(index, entry)| {
1586 SearchResult::new(index + 1, entry.title, entry.url, entry.snippet, None)
1587 })
1588 .collect()
1589 }
1590
1591 fn rerank(results: &mut [SearchResult]) {
1592 for (index, result) in results.iter_mut().enumerate() {
1593 result.rank = u8::try_from(index + 1).unwrap_or(u8::MAX);
1594 }
1595 }
1596
1597 pub(crate) fn domain_matches(url: &str, domains: &[String]) -> bool {
1598 if domains.is_empty() {
1599 return true;
1600 }
1601 let Ok(parsed) = reqwest::Url::parse(url) else {
1602 return false;
1603 };
1604 let Some(host) = parsed.host_str() else {
1605 return false;
1606 };
1607 let host = host.trim_start_matches("www.").to_ascii_lowercase();
1608 domains.iter().any(|domain| {
1609 let domain = domain.trim_start_matches("www.").to_ascii_lowercase();
1610 host == domain || host.ends_with(&format!(".{domain}"))
1611 })
1612 }
1613
1614 fn truncate_error_body(body: &str) -> String {
1615 let stripped = sanitize_error_body(body);
1616 if stripped.len() <= ERROR_BODY_PREVIEW_BYTES {
1617 stripped
1618 } else {
1619 let mut end = ERROR_BODY_PREVIEW_BYTES;
1620 while !stripped.is_char_boundary(end) {
1621 end -= 1;
1622 }
1623 format!("{}...", &stripped[..end])
1624 }
1625 }
1626
1627 static TAG_RE: OnceLock<Regex> = OnceLock::new();
1628
1629 fn get_tag_re() -> &'static Regex {
1630 TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex pattern is valid"))
1631 }
1632
1633 fn strip_html_tags(text: &str) -> String {
1634 get_tag_re().replace_all(text, "").to_string()
1635 }
1636
1637 fn sanitize_error_body(body: &str) -> String {
1638 let stripped = strip_html_tags(body);
1639 let visible: String = stripped
1640 .chars()
1641 .filter(|c| !c.is_control() || c.is_ascii_whitespace())
1642 .collect();
1643 get_bearer_token_re()
1644 .replace_all(&visible, "Bearer [REDACTED]")
1645 .to_string()
1646 }
1647
1648 fn parse_tavily_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1649 parsed
1650 .get("results")
1651 .and_then(Value::as_array)
1652 .into_iter()
1653 .flatten()
1654 .filter_map(|item| {
1655 let title = item.get("title")?.as_str()?.trim();
1656 let url = item.get("url")?.as_str()?.trim();
1657 if title.is_empty() || url.is_empty() {
1658 return None;
1659 }
1660 Some(WebSearchEntry {
1661 title: title.to_string(),
1662 url: url.to_string(),
1663 snippet: first_non_empty_string(item, &["content", "snippet"]),
1664 })
1665 })
1666 .take(max_results)
1667 .collect()
1668 }
1669
1670 fn parse_firecrawl_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1671 parsed
1672 .pointer("/data/web")
1673 .or_else(|| parsed.get("data"))
1674 .and_then(Value::as_array)
1675 .into_iter()
1676 .flatten()
1677 .filter_map(|item| {
1678 let title = item.get("title")?.as_str()?.trim();
1679 let url = item.get("url")?.as_str()?.trim();
1680 (!title.is_empty() && !url.is_empty()).then(|| WebSearchEntry {
1681 title: title.to_string(),
1682 url: url.to_string(),
1683 snippet: first_non_empty_string(item, &["description", "markdown", "content"])
1684 .map(|value| value.chars().take(1_000).collect()),
1685 })
1686 })
1687 .take(max_results)
1688 .collect()
1689 }
1690
1691 fn parse_metaso_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1692 parsed
1693 .get("webpages")
1694 .and_then(Value::as_array)
1695 .into_iter()
1696 .flatten()
1697 .filter_map(|item| {
1698 let title = item.get("title")?.as_str()?.trim();
1699 let url = item.get("link")?.as_str()?.trim();
1700 if title.is_empty() || url.is_empty() {
1701 return None;
1702 }
1703 Some(WebSearchEntry {
1704 title: title.to_string(),
1705 url: url.to_string(),
1706 snippet: first_non_empty_string(item, &["snippet", "summary"]),
1707 })
1708 })
1709 .take(max_results)
1710 .collect()
1711 }
1712
1713 fn parse_bocha_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1714 parsed
1715 .get("data")
1716 .and_then(|d| {
1717 d.get("webPages")
1718 .and_then(|w| w.get("value"))
1719 .or_else(|| d.get("pages"))
1720 })
1721 .or_else(|| parsed.get("pages"))
1722 .and_then(|v| v.as_array())
1723 .into_iter()
1724 .flat_map(|arr| arr.iter())
1725 .filter_map(|item| {
1726 let title = item
1727 .get("name")
1728 .or_else(|| item.get("title"))
1729 .and_then(|s| s.as_str())?
1730 .trim();
1731 let url = item
1732 .get("url")
1733 .or_else(|| item.get("link"))
1734 .and_then(|s| s.as_str())?
1735 .trim();
1736 if title.is_empty() || url.is_empty() {
1737 return None;
1738 }
1739 let snippet = item
1740 .get("summary")
1741 .or_else(|| item.get("snippet"))
1742 .or_else(|| item.get("description"))
1743 .and_then(|s| s.as_str())
1744 .map(str::trim)
1745 .filter(|s| !s.is_empty())
1746 .map(ToString::to_string);
1747 Some(WebSearchEntry {
1748 title: title.to_string(),
1749 url: url.to_string(),
1750 snippet,
1751 })
1752 })
1753 .take(max_results)
1754 .collect()
1755 }
1756
1757 fn bocha_error_message(parsed: &Value) -> Option<String> {
1758 let code = parsed.get("code").and_then(|v| v.as_i64())?;
1759 if code == 0 || code == 200 {
1760 return None;
1761 }
1762 let message = parsed
1763 .get("msg")
1764 .or_else(|| parsed.get("message"))
1765 .and_then(|v| v.as_str())
1766 .unwrap_or("unknown error");
1767 Some(format!("Bocha search API error (code {code}: {message})"))
1768 }
1769
1770 fn parse_baidu_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1771 parsed
1772 .get("references")
1773 .and_then(|v| v.as_array())
1774 .into_iter()
1775 .flat_map(|arr| arr.iter())
1776 .filter_map(|item| {
1777 let title = item
1778 .get("title")
1779 .or_else(|| item.get("name"))
1780 .and_then(|s| s.as_str())?
1781 .trim();
1782 let url = item
1783 .get("url")
1784 .or_else(|| item.get("link"))
1785 .and_then(|s| s.as_str())?
1786 .trim();
1787 if title.is_empty() || url.is_empty() {
1788 return None;
1789 }
1790 let snippet = item
1791 .get("content")
1792 .or_else(|| item.get("snippet"))
1793 .or_else(|| item.get("summary"))
1794 .and_then(|s| s.as_str())
1795 .map(str::trim)
1796 .filter(|s| !s.is_empty())
1797 .map(ToString::to_string);
1798 Some(WebSearchEntry {
1799 title: title.to_string(),
1800 url: url.to_string(),
1801 snippet,
1802 })
1803 })
1804 .take(max_results)
1805 .collect()
1806 }
1807
1808 /// Read a SearXNG result `score`.
1809 ///
1810 /// SearXNG emits a float, but instances and versions vary: a JSON integer, a
1811 /// numeric string, or no `score` at all are all tolerated. Unusable or
1812 /// non-finite values (`"not-a-number"`, `"NaN"`, `"inf"`, missing) read as
1813 /// `0.0`, so such rows keep their input order behind scored rows instead of
1814 /// being dropped or sorted by NaN.
1815 fn searxng_score(item: &Value) -> f64 {
1816 let raw = item.get("score");
1817 let n = raw
1818 .and_then(Value::as_f64)
1819 .or_else(|| raw.and_then(Value::as_i64).map(|i| i as f64))
1820 .or_else(|| {
1821 raw.and_then(Value::as_str)
1822 .and_then(|s| s.trim().parse().ok())
1823 })
1824 .unwrap_or(0.0);
1825 if n.is_finite() { n } else { 0.0 }
1826 }
1827
1828 /// Normalize a SearXNG JSON response into the engine-agnostic result shape.
1829 ///
1830 /// Rows without a non-empty `title` or `url` are skipped. Everything else is
1831 /// ordered by descending `score` with a stable sort (equal scores keep the
1832 /// instance's order) and only then capped, so a strong late row is not lost to
1833 /// an earlier `take` over the raw instance order.
1834 fn parse_searxng_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1835 let mut scored: Vec<(f64, WebSearchEntry)> = parsed
1836 .get("results")
1837 .and_then(|v| v.as_array())
1838 .into_iter()
1839 .flat_map(|arr| arr.iter())
1840 .filter_map(|item| {
1841 let title = item.get("title").and_then(Value::as_str)?.trim();
1842 let url = item.get("url").and_then(Value::as_str)?.trim();
1843 if title.is_empty() || url.is_empty() {
1844 return None;
1845 }
1846 let snippet = first_non_empty_string(item, &["content", "snippet"]);
1847 Some((
1848 searxng_score(item),
1849 WebSearchEntry {
1850 title: title.to_string(),
1851 url: url.to_string(),
1852 snippet,
1853 },
1854 ))
1855 })
1856 .collect();
1857
1858 scored.sort_by(|a, b| b.0.total_cmp(&a.0));
1859 scored.truncate(max_results);
1860
1861 scored.into_iter().map(|(_, entry)| entry).collect()
1862 }
1863
1864 fn baidu_error_message(parsed: &Value) -> Option<String> {
1865 let code = parsed
1866 .get("error_code")
1867 .or_else(|| parsed.get("code"))
1868 .and_then(|v| v.as_i64())?;
1869 if code == 0 {
1870 return None;
1871 }
1872 let message = parsed
1873 .get("error_msg")
1874 .or_else(|| parsed.get("message"))
1875 .and_then(|v| v.as_str())
1876 .unwrap_or("unknown error");
1877 Some(format!("Baidu search API error (code {code}: {message})"))
1878 }
1879
1880 async fn acquire_model_backed_search_inference_participant()
1881 -> crate::client::RemoteControlInferencePermit {
1882 crate::client::acquire_remote_control_inference_participant().await
1883 }
1884
1885 fn parse_sofya_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1886 parsed
1887 .get("results")
1888 .and_then(|v| v.as_array())
1889 .into_iter()
1890 .flat_map(|arr| arr.iter())
1891 .filter_map(|item| {
1892 let title = item.get("title")?.as_str()?.to_string();
1893 let url = item.get("url")?.as_str()?.to_string();
1894 let snippet = first_non_empty_string(item, &["content", "description"]);
1895 Some(WebSearchEntry {
1896 title,
1897 url,
1898 snippet,
1899 })
1900 })
1901 .take(max_results)
1902 .collect()
1903 }
1904
1905 /// Build the Serply `/v1/search` URL; `num` is the number of organic results.
1906 fn serply_search_url(query: &str, max_results: usize) -> Result<reqwest::Url, ToolError> {
1907 let mut url = reqwest::Url::parse(SERPLY_ENDPOINT)
1908 .map_err(|error| ToolError::invalid_input(format!("Invalid Serply endpoint: {error}")))?;
1909 url.query_pairs_mut()
1910 .append_pair("q", query)
1911 .append_pair("num", &max_results.to_string());
1912 Ok(url)
1913 }
1914
1915 /// Parse Serply `/v1/search` output: `results[]` rows carry `title`, `link`, and
1916 /// a `description` snippet; ads, knowledge graph, and related questions are
1917 /// top-level siblings and are ignored.
1918 fn parse_serply_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
1919 parsed
1920 .get("results")
1921 .and_then(|v| v.as_array())
1922 .into_iter()
1923 .flat_map(|arr| arr.iter())
1924 .filter_map(|item| {
1925 let title = item.get("title")?.as_str()?.to_string();
1926 let url = item.get("link")?.as_str()?.to_string();
1927 let snippet = first_non_empty_string(item, &["description", "snippet"]);
1928 Some(WebSearchEntry {
1929 title,
1930 url,
1931 snippet,
1932 })
1933 })
1934 .take(max_results)
1935 .collect()
1936 }
1937
1938 fn first_non_empty_string(item: &Value, keys: &[&str]) -> Option<String> {
1939 keys.iter().find_map(|key| {
1940 item.get(*key)
1941 .and_then(Value::as_str)
1942 .map(str::trim)
1943 .filter(|value| !value.is_empty())
1944 .map(str::to_string)
1945 })
1946 }
1947
1948 fn baidu_search_payload(query: &str, max_results: usize) -> Value {
1949 json!({
1950 "messages": [
1951 {
1952 "role": "user",
1953 "content": query,
1954 }
1955 ],
1956 "search_source": "baidu_search_v2",
1957 "resource_type_filter": [
1958 {
1959 "type": "web",
1960 "top_k": max_results,
1961 }
1962 ],
1963 })
1964 }
1965
1966 fn volcengine_search_payload(query: &str, max_results: usize) -> Value {
1967 json!({
1968 "model": "doubao-seed-2-0-lite-260428",
1969 "stream": false,
1970 "tools": [{"type": "web_search"}],
1971 "input": [{
1972 "role": "user",
1973 "content": [{
1974 "type": "input_text",
1975 "text": format!(
1976 "Search the web for: {query}\n\n\
1977 CRITICAL: Respond ONLY with a valid JSON object. No markdown, no explanation.\n\
1978 Schema: {{\"results\":[{{\"title\":\"...\",\"url\":\"https://...\",\"snippet\":\"...\"}}]}}\n\
1979 - results: 1-{max_results} most relevant pages\n\
1980 - title: page title (required)\n\
1981 - url: full URL starting with https:// (required)\n\
1982 - snippet: 1-2 sentence factual summary (required)\n\
1983 - If zero results: {{\"results\":[]}}\n\
1984 - Your entire response must be valid, parseable JSON."
1985 )
1986 }]
1987 }]
1988 })
1989 }
1990
1991 /// Extracts the model's text response from a Volcengine Responses API output.
1992 fn volcengine_extract_text(parsed: &Value) -> Option<String> {
1993 parsed
1994 .get("output")
1995 .and_then(|v| v.as_array())
1996 .into_iter()
1997 .flat_map(|arr| arr.iter().rev())
1998 .find(|item| item.get("type").and_then(|t| t.as_str()) == Some("message"))
1999 .and_then(|msg| msg.get("content").and_then(|c| c.as_array()))
2000 .and_then(|content| {
2001 content
2002 .iter()
2003 .find(|c| c.get("text").and_then(|t| t.as_str()).is_some())
2004 })
2005 .and_then(|c| c.get("text").and_then(|t| t.as_str()))
2006 .map(|s| s.to_string())
2007 }
2008
2009 /// Checks for business-logic errors in a Volcengine Responses API response.
2010 fn volcengine_error_message(parsed: &Value) -> Option<String> {
2011 let error = parsed.get("error")?;
2012 let code = error
2013 .get("code")
2014 .and_then(|v| v.as_str())
2015 .unwrap_or("unknown");
2016 let message = error
2017 .get("message")
2018 .and_then(|v| v.as_str())
2019 .unwrap_or("no details");
2020 Some(format!("Volcengine API error (code {code}: {message})"))
2021 }
2022
2023 /// Parses Volcengine model-generated JSON results into `WebSearchEntry` items.
2024 fn parse_volcengine_results(response_text: &str, max_results: usize) -> Vec<WebSearchEntry> {
2025 let json_text = extract_json_block(response_text).unwrap_or(response_text);
2026
2027 let parsed: Value = match serde_json::from_str(json_text) {
2028 Ok(v) => v,
2029 Err(_) => return Vec::new(),
2030 };
2031
2032 parsed
2033 .get("results")
2034 .and_then(|v| v.as_array())
2035 .into_iter()
2036 .flat_map(|arr| arr.iter())
2037 .filter_map(|item| {
2038 let title = item.get("title").and_then(|s| s.as_str())?.trim();
2039 let url = item.get("url").and_then(|s| s.as_str())?.trim();
2040 if title.is_empty() || url.is_empty() {
2041 return None;
2042 }
2043 let snippet = item
2044 .get("snippet")
2045 .and_then(|s| s.as_str())
2046 .map(str::trim)
2047 .filter(|s| !s.is_empty())
2048 .map(ToString::to_string);
2049 Some(WebSearchEntry {
2050 title: title.to_string(),
2051 url: url.to_string(),
2052 snippet,
2053 })
2054 })
2055 .take(max_results)
2056 .collect()
2057 }
2058
2059 /// Attempts to extract a JSON block from text that may be wrapped in
2060 /// markdown fences (```json ... ```) or contain surrounding commentary.
2061 fn extract_json_block(text: &str) -> Option<&str> {
2062 if let Some(start) = text.find("```json") {
2063 let inner = &text[start + 7..];
2064 if let Some(end) = inner.find("```") {
2065 return Some(inner[..end].trim());
2066 }
2067 }
2068 if let Some(start) = text.find('{')
2069 && let Some(end) = text.rfind('}')
2070 {
2071 return Some(&text[start..=end]);
2072 }
2073 None
2074 }
2075
2076 fn extract_search_query(input: &Value) -> Result<String, ToolError> {
2077 for key in ["query", "q"] {
2078 if let Some(value) = input.get(key) {
2079 let Some(query) = value.as_str() else {
2080 return Err(ToolError::invalid_input(format!(
2081 "Field '{key}' must be a string"
2082 )));
2083 };
2084 let query = query.trim();
2085 if !query.is_empty() {
2086 return Ok(query.to_string());
2087 }
2088 }
2089 }
2090
2091 for item in search_query_items(input) {
2092 for key in ["q", "query"] {
2093 if let Some(value) = item.get(key) {
2094 let Some(query) = value.as_str() else {
2095 return Err(ToolError::invalid_input(format!(
2096 "Field 'search_query[].{key}' must be a string"
2097 )));
2098 };
2099 let query = query.trim();
2100 if !query.is_empty() {
2101 return Ok(query.to_string());
2102 }
2103 }
2104 }
2105 }
2106
2107 Err(ToolError::missing_field("query"))
2108 }
2109
2110 fn optional_search_max_results(input: &Value) -> u64 {
2111 if let Some(value) = input.get("max_results").and_then(Value::as_u64) {
2112 return value;
2113 }
2114 search_query_items(input)
2115 .filter_map(|item| item.get("max_results").and_then(Value::as_u64))
2116 .next()
2117 .unwrap_or(DEFAULT_SEARCH_RESULTS as u64)
2118 }
2119
2120 fn search_query_from_input(input: &Value) -> Result<SearchQuery, ToolError> {
2121 let query = extract_search_query(input)?;
2122 if query.is_empty() {
2123 return Err(ToolError::invalid_input("Query cannot be empty"));
2124 }
2125 let max_results = usize::try_from(optional_search_max_results(input))
2126 .unwrap_or(DEFAULT_SEARCH_RESULTS)
2127 .clamp(1, usize::from(MAX_SEARCH_RESULTS));
2128 let recency = search_option(input, "recency")
2129 .map(parse_recency)
2130 .transpose()?;
2131 let domains = match search_option(input, "domains") {
2132 Some(value) => value
2133 .as_array()
2134 .ok_or_else(|| ToolError::invalid_input("Field 'domains' must be an array"))?
2135 .iter()
2136 .map(|value| {
2137 value.as_str().map(str::to_string).ok_or_else(|| {
2138 ToolError::invalid_input("Every 'domains' entry must be a string")
2139 })
2140 })
2141 .collect::<Result<Vec<_>, _>>()?,
2142 None => Vec::new(),
2143 };
2144 let locale = search_option(input, "locale")
2145 .map(|value| {
2146 value
2147 .as_str()
2148 .map(str::to_string)
2149 .ok_or_else(|| ToolError::invalid_input("Field 'locale' must be a string"))
2150 })
2151 .transpose()?;
2152
2153 Ok(SearchQuery::new(
2154 query,
2155 max_results,
2156 recency,
2157 domains,
2158 locale,
2159 ))
2160 }
2161
2162 fn search_option<'a>(input: &'a Value, key: &str) -> Option<&'a Value> {
2163 input
2164 .get(key)
2165 .or_else(|| search_query_items(input).find_map(|item| item.get(key)))
2166 }
2167
2168 fn parse_recency(value: &Value) -> Result<Recency, ToolError> {
2169 if let Some(days) = value.as_u64() {
2170 let days = u16::try_from(days)
2171 .ok()
2172 .filter(|days| (1..=3650).contains(days))
2173 .ok_or_else(|| {
2174 ToolError::invalid_input("Field 'recency' must be between 1 and 3650 days")
2175 })?;
2176 return Ok(Recency::Days(days));
2177 }
2178 match value.as_str() {
2179 Some("day") => Ok(Recency::Day),
2180 Some("week") => Ok(Recency::Week),
2181 Some("month") => Ok(Recency::Month),
2182 Some("year") => Ok(Recency::Year),
2183 _ => Err(ToolError::invalid_input(
2184 "Field 'recency' must be day, week, month, year, or an integer day count",
2185 )),
2186 }
2187 }
2188
2189 fn search_query_items(input: &Value) -> impl Iterator<Item = &Value> {
2190 input
2191 .get("search_query")
2192 .and_then(Value::as_array)
2193 .into_iter()
2194 .flat_map(|items| items.iter())
2195 }
2196
2197 async fn run_bing_search(
2198 client: &reqwest::Client,
2199 query: &str,
2200 max_results: usize,
2201 endpoint: &str,
2202 ) -> Result<Vec<WebSearchEntry>, ToolError> {
2203 let mut url = reqwest::Url::parse(endpoint)
2204 .map_err(|error| ToolError::invalid_input(format!("Invalid Bing endpoint: {error}")))?;
2205 url.query_pairs_mut().append_pair("q", query);
2206 let resp = client
2207 .get(url)
2208 .header(
2209 "Accept",
2210 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
2211 )
2212 .header("Accept-Language", "en-US,en;q=0.9")
2213 .send()
2214 .await
2215 .map_err(|e| ToolError::execution_failed(format!("Bing search request failed: {e}")))?;
2216
2217 let status = resp.status();
2218 let body = resp.text().await.map_err(|e| {
2219 ToolError::execution_failed(format!("Failed to read Bing search response: {e}"))
2220 })?;
2221
2222 if !status.is_success() {
2223 return Err(ToolError::execution_failed(format!(
2224 "Bing search failed: HTTP {}",
2225 status.as_u16()
2226 )));
2227 }
2228
2229 Ok(parse_bing_results(&body, max_results))
2230 }
2231
2232 fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
2233 scrape_duckduckgo_results(html, max_results)
2234 .into_iter()
2235 .map(web_search_entry_from_scraped)
2236 .collect()
2237 }
2238
2239 fn parse_bing_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
2240 scrape_bing_results(html, max_results)
2241 .into_iter()
2242 .map(web_search_entry_from_scraped)
2243 .collect()
2244 }
2245
2246 fn web_search_entry_from_scraped(entry: ScrapedSearchResult) -> WebSearchEntry {
2247 WebSearchEntry {
2248 title: entry.title,
2249 url: entry.url,
2250 snippet: entry.snippet,
2251 }
2252 }
2253
2254 fn duckduckgo_search_url(
2255 base_url: Option<&str>,
2256 query: &str,
2257 ) -> Result<(String, String), ToolError> {
2258 let raw = configured_search_base_url(base_url).unwrap_or(DUCKDUCKGO_ENDPOINT);
2259 let mut url = reqwest::Url::parse(raw).map_err(|err| {
2260 ToolError::invalid_input(format!(
2261 "Invalid DuckDuckGo-compatible search base_url: {err}"
2262 ))
2263 })?;
2264 url.query_pairs_mut().append_pair("q", query);
2265 let host = url.host_str().ok_or_else(|| {
2266 ToolError::invalid_input("DuckDuckGo-compatible search base_url must include a host")
2267 })?;
2268 Ok((url.to_string(), host.to_string()))
2269 }
2270
2271 fn searxng_search_url(base_url: Option<&str>, query: &str) -> Result<(String, String), ToolError> {
2272 let raw = configured_search_base_url(base_url).ok_or_else(|| {
2273 ToolError::invalid_input(
2274 "SearXNG search requires [search] base_url = \"https://your-searxng.example\"; no public instance is used by default.",
2275 )
2276 })?;
2277 let mut url = reqwest::Url::parse(raw).map_err(|err| {
2278 ToolError::invalid_input(format!("Invalid SearXNG search base_url: {err}"))
2279 })?;
2280 let host = url
2281 .host_str()
2282 .ok_or_else(|| ToolError::invalid_input("SearXNG search base_url must include a host"))?
2283 .to_string();
2284
2285 let path = url.path().trim_end_matches('/');
2286 if path.is_empty() {
2287 url.set_path("search");
2288 } else if path != "/search" && !path.ends_with("/search") {
2289 url.set_path(&format!("{path}/search"));
2290 }
2291 url.query_pairs_mut()
2292 .append_pair("q", query)
2293 .append_pair("format", "json");
2294
2295 Ok((url.to_string(), host))
2296 }
2297
2298 fn configured_search_base_url(base_url: Option<&str>) -> Option<&str> {
2299 base_url.map(str::trim).filter(|value| !value.is_empty())
2300 }
2301
2302 fn duckduckgo_allows_bing_fallback(base_url: Option<&str>) -> bool {
2303 configured_search_base_url(base_url).is_none()
2304 }
2305
2306 #[cfg(test)]
2307 mod tests {
2308 use super::{
2309 ERROR_BODY_PREVIEW_BYTES, KIMI_K3_FORMULA_MIN_TIMEOUT_MS, ScrapeEndpoints,
2310 SearchProbeTargetError, WebSearchTool, acquire_model_backed_search_inference_participant,
2311 baidu_search_payload, bocha_error_message, domain_matches, duckduckgo_search_url,
2312 extract_search_query, finalize_search_response, optional_search_max_results,
2313 parse_baidu_results, parse_bocha_results, parse_metaso_results, parse_searxng_results,
2314 parse_serply_results, parse_sofya_results, parse_tavily_results, parse_volcengine_results,
2315 register_search_citations, rerank, run_scrape_search_with_endpoints, sanitize_error_body,
2316 search_probe_target, search_timeout_budgets, searxng_score, searxng_search_url,
2317 serply_search_url, truncate_error_body, volcengine_extract_text,
2318 };
2319 use crate::config::SearchProvider;
2320 use crate::tools::web::contract::{
2321 BackendId, BackendSearch, CapabilityState, DegradedReason, QueryCapabilities, QueryKnob,
2322 Recency, SearchQuery, SearchResult,
2323 };
2324 use crate::tools::web::scrape::{decode_html_entities, normalize_bing_url};
2325 use serde_json::json;
2326 use std::time::{Duration, Instant};
2327
2328 #[test]
2329 fn provider_native_receives_dedicated_budget_without_extending_fallback() {
2330 let requested = Duration::from_millis(15_000);
2331 let (total, first, fallback) =
2332 search_timeout_budgets(BackendId::ProviderNative, requested, None);
2333
2334 assert_eq!(total, Duration::from_millis(60_000));
2335 assert_eq!(first, Some(Duration::from_millis(45_000)));
2336 assert_eq!(fallback, Some(requested));
2337
2338 let (total, first, fallback) = search_timeout_budgets(
2339 BackendId::ProviderNative,
2340 requested,
2341 Some(Duration::from_millis(KIMI_K3_FORMULA_MIN_TIMEOUT_MS)),
2342 );
2343 assert_eq!(total, Duration::from_millis(195_000));
2344 assert_eq!(first, Some(Duration::from_millis(180_000)));
2345 assert_eq!(fallback, Some(requested));
2346 }
2347
2348 #[test]
2349 fn doctor_search_probe_targets_cover_every_builtin_provider() {
2350 let cases = [
2351 (SearchProvider::Bing, "https://www.bing.com/search"),
2352 (
2353 SearchProvider::DuckDuckGo,
2354 "https://html.duckduckgo.com/html/",
2355 ),
2356 (
2357 SearchProvider::Firecrawl,
2358 "https://api.firecrawl.dev/v2/search",
2359 ),
2360 (SearchProvider::Tavily, "https://api.tavily.com/search"),
2361 (
2362 SearchProvider::Bocha,
2363 "https://api.bochaai.com/v1/web-search",
2364 ),
2365 (SearchProvider::Metaso, "https://metaso.cn/api/v1"),
2366 (
2367 SearchProvider::Baidu,
2368 "https://qianfan.baidubce.com/v2/ai_search/web_search",
2369 ),
2370 (
2371 SearchProvider::Volcengine,
2372 "https://ark.cn-beijing.volces.com/api/v3/responses",
2373 ),
2374 (SearchProvider::Sofya, "https://sofya.co/v1/search"),
2375 (SearchProvider::Serply, "https://api.serply.io/v1/search"),
2376 ];
2377
2378 for (provider, expected) in cases {
2379 let target = search_probe_target(provider, None).expect("built-in target");
2380 assert_eq!(target.url.as_str(), expected, "{provider:?}");
2381 assert_eq!(target.host, target.url.host_str().unwrap(), "{provider:?}");
2382 }
2383 }
2384
2385 #[test]
2386 fn doctor_search_probe_strips_every_secret_capable_custom_url_component() {
2387 let target = search_probe_target(
2388 SearchProvider::Searxng,
2389 Some(
2390 "https://URL-USER:URL-PASSWORD@search.example:8443/private/URL-PATH?URL-QUERY=secret#URL-FRAGMENT",
2391 ),
2392 )
2393 .expect("credential-free target");
2394
2395 assert_eq!(target.url.as_str(), "https://search.example:8443/");
2396 assert_eq!(target.host, "search.example");
2397 }
2398
2399 #[test]
2400 fn doctor_search_probe_rejects_configuration_that_runtime_cannot_use() {
2401 assert_eq!(
2402 search_probe_target(SearchProvider::Searxng, None),
2403 Err(SearchProbeTargetError::Missing)
2404 );
2405 assert_eq!(
2406 search_probe_target(SearchProvider::Tavily, Some("https://ignored.example")),
2407 Err(SearchProbeTargetError::Unsupported)
2408 );
2409 assert_eq!(
2410 search_probe_target(SearchProvider::DuckDuckGo, Some("file:///tmp/search")),
2411 Err(SearchProbeTargetError::Invalid)
2412 );
2413 }
2414
2415 #[test]
2416 fn bing_ckurl_with_html_entities_decodes_real_url() {
2417 let href = "https://www.bing.com/ck/a?!&amp;&amp;p=abc&amp;u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&amp;ntb=1";
2418 assert_eq!(normalize_bing_url(href), "https://rust-lang.org/");
2419 }
2420
2421 #[test]
2422 fn decode_html_entities_handles_named_entities() {
2423 assert_eq!(decode_html_entities("&amp;"), "&");
2424 assert_eq!(decode_html_entities("&lt;"), "<");
2425 assert_eq!(decode_html_entities("&gt;"), ">");
2426 assert_eq!(decode_html_entities("&quot;"), "\"");
2427 assert_eq!(decode_html_entities("&apos;"), "'");
2428 assert_eq!(decode_html_entities("&nbsp;"), " ");
2429 assert_eq!(decode_html_entities("&copy;"), "\u{00A9}");
2430 assert_eq!(decode_html_entities("&mdash;"), "\u{2014}");
2431 }
2432
2433 #[test]
2434 fn decode_html_entities_handles_decimal_numeric_references() {
2435 assert_eq!(decode_html_entities("&#65;"), "A");
2436 assert_eq!(decode_html_entities("&#60;"), "<");
2437 assert_eq!(decode_html_entities("&#8211;"), "\u{2013}");
2438 }
2439
2440 #[test]
2441 fn decode_html_entities_handles_hex_numeric_references() {
2442 assert_eq!(decode_html_entities("&#x41;"), "A");
2443 assert_eq!(decode_html_entities("&#x3C;"), "<");
2444 assert_eq!(decode_html_entities("&#x2014;"), "\u{2014}");
2445 }
2446
2447 #[test]
2448 fn decode_html_entities_passthrough_unknown() {
2449 assert_eq!(decode_html_entities("&unknown;"), "&unknown;");
2450 }
2451
2452 #[test]
2453 fn decode_html_entities_mixed_content() {
2454 let input = "Hello &amp; welcome to &quot;Rust&apos;s world&quot; &mdash; enjoy!";
2455 let expected = "Hello & welcome to \"Rust's world\" \u{2014} enjoy!";
2456 assert_eq!(decode_html_entities(input), expected);
2457 }
2458
2459 #[test]
2460 fn extract_search_query_accepts_legacy_query() {
2461 let query =
2462 extract_search_query(&json!({"query": " deepseek v4 "})).expect("query should parse");
2463 assert_eq!(query, "deepseek v4");
2464 }
2465
2466 #[test]
2467 fn extract_search_query_accepts_q_alias() {
2468 let query =
2469 extract_search_query(&json!({"q": "deepseek v4 pro"})).expect("q alias should parse");
2470 assert_eq!(query, "deepseek v4 pro");
2471 }
2472
2473 #[test]
2474 fn extract_search_query_accepts_array_form() {
2475 let input = json!({"search_query": [{"q": "deepseek api", "max_results": 3}]});
2476 let query = extract_search_query(&input).expect("array form should parse");
2477 assert_eq!(query, "deepseek api");
2478 assert_eq!(optional_search_max_results(&input), 3);
2479 }
2480
2481 #[test]
2482 fn extract_search_query_rejects_missing_query() {
2483 let err = extract_search_query(&json!({"max_results": 2}))
2484 .expect_err("missing query should fail");
2485 assert!(format!("{err}").contains("missing required field 'query'"));
2486 }
2487
2488 #[test]
2489 fn optional_max_results_prefers_top_level_value() {
2490 assert_eq!(
2491 optional_search_max_results(
2492 &json!({"query": "x", "max_results": 8, "search_query": [{"q": "y", "max_results": 2}]})
2493 ),
2494 8,
2495 );
2496 }
2497
2498 #[test]
2499 fn optional_max_results_falls_back_to_array_form() {
2500 assert_eq!(
2501 optional_search_max_results(&json!({"search_query": [{"q": "y", "max_results": 3}]})),
2502 3,
2503 );
2504 }
2505
2506 #[test]
2507 fn optional_max_results_uses_default_when_neither_set() {
2508 assert_eq!(optional_search_max_results(&json!({"query": "x"})), 5);
2509 assert_eq!(
2510 optional_search_max_results(&json!({"search_query": [{"q": "y"}]})),
2511 5,
2512 );
2513 }
2514
2515 #[test]
2516 fn optional_max_results_only_reads_first_array_entry() {
2517 assert_eq!(
2518 optional_search_max_results(
2519 &json!({"search_query": [{"q": "first", "max_results": 1}, {"q": "second", "max_results": 9}]})
2520 ),
2521 1,
2522 );
2523 }
2524
2525 #[test]
2526 fn extract_search_query_trims_whitespace_from_array_form_q_alias() {
2527 let q = extract_search_query(&json!({"search_query": [{"q": " deepseek tui "}]}))
2528 .expect("array form should parse with trim");
2529 assert_eq!(q, "deepseek tui");
2530 }
2531
2532 #[test]
2533 fn extract_search_query_rejects_empty_query() {
2534 for body in [json!({"query": ""}), json!({"q": " "}), json!({})] {
2535 let err = extract_search_query(&body).expect_err("empty query must reject");
2536 let msg = format!("{err}");
2537 assert!(
2538 msg.contains("missing required field 'query'") || msg.contains("Query"),
2539 "expected query-missing error, got `{msg}`"
2540 );
2541 }
2542 }
2543
2544 #[test]
2545 fn truncate_error_body_truncates_long_body() {
2546 let body = "a".repeat(ERROR_BODY_PREVIEW_BYTES + 100);
2547 let truncated = truncate_error_body(&body);
2548 assert!(truncated.len() <= ERROR_BODY_PREVIEW_BYTES + 3);
2549 assert!(truncated.ends_with("..."));
2550 }
2551
2552 #[test]
2553 fn truncate_error_body_keeps_short_body_intact() {
2554 let body = "short error";
2555 assert_eq!(truncate_error_body(body), body);
2556 }
2557
2558 #[test]
2559 fn sanitize_error_body_strips_html_and_control_chars() {
2560 let body = "<p>error</p>\x00\x01\x02";
2561 let sanitized = sanitize_error_body(body);
2562 assert_eq!(sanitized, "error");
2563 }
2564
2565 #[test]
2566 fn sanitize_error_body_redacts_bearer_tokens() {
2567 let body = r#"{"error":"bad token","authorization":"Bearer test-token/with+chars="}"#;
2568
2569 let sanitized = sanitize_error_body(body);
2570
2571 assert!(!sanitized.contains("test-token/with+chars="));
2572 assert!(sanitized.contains("Bearer [REDACTED]"));
2573 }
2574
2575 #[test]
2576 fn parse_bocha_web_pages_value_extracts_ranked_results() {
2577 let body = json!({
2578 "code": 200,
2579 "msg": null,
2580 "data": {
2581 "webPages": {
2582 "value": [
2583 {
2584 "name": "广州天气",
2585 "url": "https://bocha.cn/share/weather",
2586 "snippet": "广州今日雷阵雨转晴。"
2587 },
2588 {
2589 "name": "中央气象台",
2590 "url": "https://www.weather.com.cn/",
2591 "summary": "天气实况。"
2592 }
2593 ]
2594 }
2595 }
2596 });
2597
2598 let results = parse_bocha_results(&body, 10);
2599
2600 assert_eq!(results.len(), 2);
2601 assert_eq!(results[0].title, "广州天气");
2602 assert_eq!(results[0].url, "https://bocha.cn/share/weather");
2603 assert_eq!(results[0].snippet.as_deref(), Some("广州今日雷阵雨转晴。"));
2604 assert_eq!(results[1].title, "中央气象台");
2605 }
2606
2607 #[test]
2608 fn parse_bocha_keeps_legacy_pages_shape() {
2609 let body = json!({
2610 "code": 200,
2611 "data": {
2612 "pages": [
2613 {
2614 "title": "Legacy title",
2615 "link": "https://example.com/legacy",
2616 "description": "Legacy description"
2617 }
2618 ]
2619 }
2620 });
2621
2622 let results = parse_bocha_results(&body, 5);
2623
2624 assert_eq!(results.len(), 1);
2625 assert_eq!(results[0].title, "Legacy title");
2626 assert_eq!(results[0].url, "https://example.com/legacy");
2627 assert_eq!(results[0].snippet.as_deref(), Some("Legacy description"));
2628 }
2629
2630 #[test]
2631 fn bocha_error_message_flags_non_success_business_code() {
2632 let body = json!({"code": 401, "msg": "invalid api key"});
2633
2634 let error = bocha_error_message(&body).expect("non-success code should error");
2635
2636 assert!(error.contains("Bocha"));
2637 assert!(error.contains("401"));
2638 assert!(error.contains("invalid api key"));
2639 }
2640
2641 #[test]
2642 fn parse_baidu_references_extracts_ranked_results() {
2643 let body = json!({
2644 "references": [
2645 {
2646 "title": "Rust 官方文档",
2647 "url": "https://www.rust-lang.org/",
2648 "content": "Rust 是一门注重性能和可靠性的语言。"
2649 },
2650 {
2651 "title": "Cargo Book",
2652 "url": "https://doc.rust-lang.org/cargo/",
2653 "snippet": "Cargo is Rust's package manager."
2654 }
2655 ]
2656 });
2657
2658 let results = parse_baidu_results(&body, 10);
2659
2660 assert_eq!(results.len(), 2);
2661 assert_eq!(results[0].title, "Rust 官方文档");
2662 assert_eq!(results[0].url, "https://www.rust-lang.org/");
2663 assert_eq!(
2664 results[0].snippet.as_deref(),
2665 Some("Rust 是一门注重性能和可靠性的语言。")
2666 );
2667 assert_eq!(results[1].title, "Cargo Book");
2668 assert_eq!(results[1].url, "https://doc.rust-lang.org/cargo/");
2669 assert_eq!(
2670 results[1].snippet.as_deref(),
2671 Some("Cargo is Rust's package manager.")
2672 );
2673 }
2674
2675 #[test]
2676 fn parse_baidu_references_skips_incomplete_entries() {
2677 let body = json!({
2678 "references": [
2679 {"title": "No URL", "content": "missing url"},
2680 {"url": "https://example.com/no-title", "content": "missing title"},
2681 {"title": "Valid", "url": "https://example.com/valid"}
2682 ]
2683 });
2684
2685 let results = parse_baidu_results(&body, 10);
2686
2687 assert_eq!(results.len(), 1);
2688 assert_eq!(results[0].title, "Valid");
2689 assert_eq!(results[0].url, "https://example.com/valid");
2690 assert_eq!(results[0].snippet, None);
2691 }
2692
2693 #[test]
2694 fn baidu_search_payload_uses_official_search_source() {
2695 let payload = baidu_search_payload("Rust cargo workspace", 3);
2696
2697 assert_eq!(
2698 payload.get("search_source").and_then(|v| v.as_str()),
2699 Some("baidu_search_v2")
2700 );
2701 assert_eq!(
2702 payload
2703 .get("messages")
2704 .and_then(|v| v.as_array())
2705 .and_then(|messages| messages.first())
2706 .and_then(|message| message.get("content"))
2707 .and_then(|v| v.as_str()),
2708 Some("Rust cargo workspace")
2709 );
2710 assert_eq!(
2711 payload
2712 .get("resource_type_filter")
2713 .and_then(|v| v.as_array())
2714 .and_then(|filters| filters.first())
2715 .and_then(|filter| filter.get("top_k"))
2716 .and_then(|v| v.as_u64()),
2717 Some(3)
2718 );
2719 }
2720
2721 #[test]
2722 fn serply_search_url_encodes_query_and_result_count() {
2723 let url = serply_search_url("rust tui & ratatui", 7).expect("serply url");
2724
2725 assert_eq!(url.host_str(), Some("api.serply.io"));
2726 assert_eq!(url.path(), "/v1/search");
2727 let pairs: Vec<(String, String)> = url
2728 .query_pairs()
2729 .map(|(k, v)| (k.into_owned(), v.into_owned()))
2730 .collect();
2731 assert_eq!(
2732 pairs,
2733 vec![
2734 ("q".to_string(), "rust tui & ratatui".to_string()),
2735 ("num".to_string(), "7".to_string()),
2736 ]
2737 );
2738 }
2739
2740 #[test]
2741 fn parse_serply_results_reads_link_and_description_and_skips_malformed_rows() {
2742 let body = json!({
2743 "results": [
2744 {
2745 "title": "Ratatui",
2746 "link": "https://ratatui.rs/",
2747 "description": "Cook up delicious terminal user interfaces in Rust.",
2748 "position": 1,
2749 "realPosition": 1
2750 },
2751 {
2752 "title": "No description",
2753 "link": "https://example.com/plain",
2754 "description": ""
2755 },
2756 {
2757 "title": "Missing link",
2758 "description": "dropped because there is no link"
2759 },
2760 "not an object",
2761 {
2762 "title": "Fourth",
2763 "link": "https://example.com/fourth",
2764 "description": "beyond max_results"
2765 }
2766 ],
2767 "knowledge_graph": {"title": "ignored sidebar"},
2768 "related_questions": [{"question": "ignored"}],
2769 "ads": [{"title": "ignored ad", "link": "https://ads.example.com"}]
2770 });
2771
2772 let results = parse_serply_results(&body, 2);
2773
2774 assert_eq!(results.len(), 2);
2775 assert_eq!(results[0].title, "Ratatui");
2776 assert_eq!(results[0].url, "https://ratatui.rs/");
2777 assert_eq!(
2778 results[0].snippet.as_deref(),
2779 Some("Cook up delicious terminal user interfaces in Rust.")
2780 );
2781 assert_eq!(results[1].url, "https://example.com/plain");
2782 assert_eq!(results[1].snippet, None);
2783
2784 assert!(parse_serply_results(&json!({"total": 0}), 5).is_empty());
2785 }
2786
2787 #[test]
2788 fn parse_sofya_results_falls_back_to_description_for_empty_content() {
2789 let body = json!({
2790 "results": [
2791 {
2792 "title": "Full content",
2793 "url": "https://example.com/full",
2794 "content": "full extracted page content",
2795 "description": "unused description"
2796 },
2797 {
2798 "title": "Null content",
2799 "url": "https://example.com/null",
2800 "content": null,
2801 "description": "description for null content"
2802 },
2803 {
2804 "title": "Empty content",
2805 "url": "https://example.com/empty",
2806 "content": "",
2807 "description": "description for empty content"
2808 },
2809 {
2810 "title": "Whitespace content",
2811 "url": "https://example.com/blank",
2812 "content": " ",
2813 "description": "description for blank content"
2814 },
2815 {
2816 "title": "No snippet",
2817 "url": "https://example.com/no-snippet"
2818 }
2819 ]
2820 });
2821
2822 let results = parse_sofya_results(&body, 10);
2823
2824 assert_eq!(results.len(), 5);
2825 assert_eq!(
2826 results[0].snippet.as_deref(),
2827 Some("full extracted page content")
2828 );
2829 assert_eq!(
2830 results[1].snippet.as_deref(),
2831 Some("description for null content")
2832 );
2833 assert_eq!(
2834 results[2].snippet.as_deref(),
2835 Some("description for empty content")
2836 );
2837 assert_eq!(
2838 results[3].snippet.as_deref(),
2839 Some("description for blank content")
2840 );
2841 assert_eq!(results[4].snippet, None);
2842 }
2843
2844 #[test]
2845 fn tavily_metaso_and_volcengine_payloads_use_normalized_entry_shape() {
2846 let tavily = parse_tavily_results(
2847 &json!({"results": [{
2848 "title": " Tavily result ",
2849 "url": "https://tavily.example/result",
2850 "content": " content "
2851 }]}),
2852 5,
2853 );
2854 let metaso = parse_metaso_results(
2855 &json!({"webpages": [{
2856 "title": " Metaso result ",
2857 "link": "https://metaso.example/result",
2858 "summary": " summary "
2859 }]}),
2860 5,
2861 );
2862 let volcengine = parse_volcengine_results(
2863 r#"{"results":[{"title":"Volcengine result","url":"https://volc.example/result","snippet":"summary"}]}"#,
2864 5,
2865 );
2866
2867 for (entries, title, snippet) in [
2868 (tavily, "Tavily result", "content"),
2869 (metaso, "Metaso result", "summary"),
2870 (volcengine, "Volcengine result", "summary"),
2871 ] {
2872 assert_eq!(entries.len(), 1);
2873 assert_eq!(entries[0].title, title);
2874 assert_eq!(entries[0].snippet.as_deref(), Some(snippet));
2875 }
2876 }
2877
2878 #[tokio::test]
2879 async fn firecrawl_keyless_request_is_headerless_and_keyed_request_is_explicit() {
2880 use wiremock::matchers::{method, path};
2881 use wiremock::{Mock, MockServer, ResponseTemplate};
2882
2883 let server = MockServer::start().await;
2884 Mock::given(method("POST"))
2885 .and(path("/v2/search"))
2886 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2887 "success": true,
2888 "data": {"web": [{
2889 "title": "Firecrawl result",
2890 "url": "https://example.com/firecrawl",
2891 "description": "x".repeat(1_200)
2892 }]}
2893 })))
2894 .mount(&server)
2895 .await;
2896 let endpoint = format!("{}/v2/search", server.uri());
2897 let (entries, mode) = WebSearchTool
2898 .run_firecrawl_search_at(&endpoint, "codewhale", 5, 5_000, None)
2899 .await
2900 .expect("keyless Firecrawl search");
2901 WebSearchTool
2902 .run_firecrawl_search_at(&endpoint, "codewhale", 5, 5_000, Some("fc-secret"))
2903 .await
2904 .expect("authenticated Firecrawl search");
2905 let requests = server.received_requests().await.expect("recorded requests");
2906 let payload: serde_json::Value =
2907 serde_json::from_slice(&requests[0].body).expect("request JSON");
2908
2909 assert_eq!(entries[0].title, "Firecrawl result");
2910 assert_eq!(entries[0].url, "https://example.com/firecrawl");
2911 assert_eq!(
2912 entries[0].snippet.as_deref().unwrap().chars().count(),
2913 1_000
2914 );
2915 assert_eq!(mode, "Firecrawl keyless");
2916 assert!(requests[0].headers.get("authorization").is_none());
2917 assert_eq!(requests[1].headers["authorization"], "Bearer fc-secret");
2918 assert!(payload.get("integration").is_none());
2919 assert_eq!(payload["sources"][0]["type"], "web");
2920 }
2921
2922 #[tokio::test]
2923 async fn firecrawl_keyless_rate_limit_is_actionable() {
2924 use wiremock::matchers::{method, path};
2925 use wiremock::{Mock, MockServer, ResponseTemplate};
2926
2927 let server = MockServer::start().await;
2928 Mock::given(method("POST"))
2929 .and(path("/v2/search"))
2930 .respond_with(ResponseTemplate::new(429))
2931 .mount(&server)
2932 .await;
2933 let error = WebSearchTool
2934 .run_firecrawl_search_at(
2935 &format!("{}/v2/search", server.uri()),
2936 "quota",
2937 5,
2938 5_000,
2939 None,
2940 )
2941 .await
2942 .expect_err("429 must be actionable");
2943 assert!(error.to_string().contains("keyless quota is exhausted"));
2944 assert!(error.to_string().contains("FIRECRAWL_API_KEY"));
2945 }
2946
2947 #[test]
2948 fn volcengine_extract_text_skips_non_text_content_blocks() {
2949 let body = json!({
2950 "output": [
2951 {
2952 "type": "message",
2953 "content": [
2954 {"type": "reasoning", "summary": "thinking first"},
2955 {"type": "output_text", "text": "{\"results\":[]}"}
2956 ]
2957 }
2958 ]
2959 });
2960
2961 assert_eq!(
2962 volcengine_extract_text(&body).as_deref(),
2963 Some("{\"results\":[]}")
2964 );
2965 }
2966
2967 #[tokio::test]
2968 async fn volcengine_model_search_waits_for_runtime_chat_ownership() {
2969 let ownership = crate::client::acquire_runtime_chat_inference_ownership().await;
2970 let mut participant =
2971 tokio::spawn(async { acquire_model_backed_search_inference_participant().await });
2972 assert!(
2973 tokio::time::timeout(std::time::Duration::from_millis(40), &mut participant)
2974 .await
2975 .is_err(),
2976 "model-backed web search must wait behind Runtime Chat ownership"
2977 );
2978 drop(ownership);
2979 let permit = tokio::time::timeout(std::time::Duration::from_secs(1), participant)
2980 .await
2981 .expect("model-backed search resumes after relay settlement")
2982 .expect("model-backed search participant task");
2983 drop(permit);
2984 }
2985
2986 #[tokio::test]
2987 async fn baidu_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
2988 use crate::config::SearchProvider;
2989 use crate::tools::spec::{ToolContext, ToolSpec};
2990
2991 let prev = std::env::var_os("BAIDU_SEARCH_API_KEY");
2992 unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") };
2993
2994 let tmp = tempfile::tempdir().expect("tempdir");
2995 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
2996 ctx.search_provider = SearchProvider::Baidu;
2997 ctx.search_api_key = None;
2998 let err = WebSearchTool
2999 .execute(json!({"query": "anything"}), &ctx)
3000 .await
3001 .expect_err("missing api_key must surface as ToolError");
3002
3003 match prev {
3004 Some(value) => unsafe { std::env::set_var("BAIDU_SEARCH_API_KEY", value) },
3005 None => unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") },
3006 }
3007
3008 let msg = err.to_string();
3009 assert!(
3010 msg.contains("Baidu") && msg.contains("API key"),
3011 "error must name the provider and missing key; got `{msg}`"
3012 );
3013 }
3014
3015 #[tokio::test]
3016 #[allow(clippy::await_holding_lock)]
3017 async fn serply_missing_key_is_fail_closed_inside_the_backend_chain() {
3018 use crate::tools::spec::ToolContext;
3019
3020 let _guard = crate::test_support::lock_test_env();
3021 let prev = std::env::var_os("SERPLY_API_KEY");
3022 unsafe { std::env::remove_var("SERPLY_API_KEY") };
3023
3024 let tmp = tempfile::tempdir().expect("tempdir");
3025 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3026 ctx.search_api_key = None;
3027 let err = WebSearchTool
3028 .run_serply_search("anything", 5, 1_000, &ctx)
3029 .await
3030 .expect_err("missing api_key must be an error");
3031
3032 match prev {
3033 Some(value) => unsafe { std::env::set_var("SERPLY_API_KEY", value) },
3034 None => unsafe { std::env::remove_var("SERPLY_API_KEY") },
3035 }
3036
3037 // A configured Serply route that reaches the adapter after a failed
3038 // provider-native attempt must stop the chain, not degrade to DuckDuckGo.
3039 assert!(
3040 matches!(err, crate::tools::spec::ToolError::InvalidInput { .. }),
3041 "missing key must be classified fail-closed; got `{err:?}`"
3042 );
3043 }
3044
3045 #[tokio::test]
3046 #[allow(clippy::await_holding_lock)]
3047 async fn serply_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
3048 use crate::config::SearchProvider;
3049 use crate::tools::spec::{ToolContext, ToolSpec};
3050
3051 let _guard = crate::test_support::lock_test_env();
3052 let prev = std::env::var_os("SERPLY_API_KEY");
3053 unsafe { std::env::remove_var("SERPLY_API_KEY") };
3054
3055 let tmp = tempfile::tempdir().expect("tempdir");
3056 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3057 ctx.search_provider = SearchProvider::Serply;
3058 ctx.search_api_key = None;
3059 let err = WebSearchTool
3060 .execute(json!({"query": "anything"}), &ctx)
3061 .await
3062 .expect_err("missing api_key must surface as ToolError");
3063
3064 match prev {
3065 Some(value) => unsafe { std::env::set_var("SERPLY_API_KEY", value) },
3066 None => unsafe { std::env::remove_var("SERPLY_API_KEY") },
3067 }
3068
3069 let msg = err.to_string();
3070 assert!(
3071 msg.contains("Serply") && msg.contains("API key"),
3072 "error must name the provider and missing key; got `{msg}`"
3073 );
3074 }
3075
3076 #[tokio::test]
3077 #[allow(clippy::await_holding_lock)]
3078 async fn sofya_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
3079 use crate::config::SearchProvider;
3080 use crate::tools::spec::{ToolContext, ToolSpec};
3081
3082 let _guard = crate::test_support::lock_test_env();
3083 let prev = std::env::var_os("SOFYA_API_KEY");
3084 unsafe { std::env::remove_var("SOFYA_API_KEY") };
3085
3086 let tmp = tempfile::tempdir().expect("tempdir");
3087 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3088 ctx.search_provider = SearchProvider::Sofya;
3089 ctx.search_api_key = None;
3090 let err = WebSearchTool
3091 .execute(json!({"query": "anything"}), &ctx)
3092 .await
3093 .expect_err("missing api_key must surface as ToolError");
3094
3095 match prev {
3096 Some(value) => unsafe { std::env::set_var("SOFYA_API_KEY", value) },
3097 None => unsafe { std::env::remove_var("SOFYA_API_KEY") },
3098 }
3099
3100 let msg = err.to_string();
3101 assert!(
3102 msg.contains("Sofya") && msg.contains("API key"),
3103 "error must name the provider and missing key; got `{msg}`"
3104 );
3105 }
3106
3107 #[tokio::test]
3108 #[allow(clippy::await_holding_lock)]
3109 async fn volcengine_provider_without_api_key_lists_supported_env_fallbacks() {
3110 use crate::config::SearchProvider;
3111 use crate::tools::spec::{ToolContext, ToolSpec};
3112
3113 let _guard = crate::test_support::lock_test_env();
3114 let prev_volc = std::env::var_os("VOLCENGINE_API_KEY");
3115 let prev_volc_ark = std::env::var_os("VOLCENGINE_ARK_API_KEY");
3116 let prev_ark = std::env::var_os("ARK_API_KEY");
3117 unsafe {
3118 std::env::remove_var("VOLCENGINE_API_KEY");
3119 std::env::remove_var("VOLCENGINE_ARK_API_KEY");
3120 std::env::remove_var("ARK_API_KEY");
3121 }
3122
3123 let tmp = tempfile::tempdir().expect("tempdir");
3124 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3125 ctx.search_provider = SearchProvider::Volcengine;
3126 ctx.search_api_key = None;
3127 let err = WebSearchTool
3128 .execute(json!({"query": "anything"}), &ctx)
3129 .await
3130 .expect_err("missing api_key must surface as ToolError");
3131
3132 match prev_volc {
3133 Some(value) => unsafe { std::env::set_var("VOLCENGINE_API_KEY", value) },
3134 None => unsafe { std::env::remove_var("VOLCENGINE_API_KEY") },
3135 }
3136 match prev_volc_ark {
3137 Some(value) => unsafe { std::env::set_var("VOLCENGINE_ARK_API_KEY", value) },
3138 None => unsafe { std::env::remove_var("VOLCENGINE_ARK_API_KEY") },
3139 }
3140 match prev_ark {
3141 Some(value) => unsafe { std::env::set_var("ARK_API_KEY", value) },
3142 None => unsafe { std::env::remove_var("ARK_API_KEY") },
3143 }
3144
3145 let msg = err.to_string();
3146 assert!(msg.contains("Volcengine") && msg.contains("API key"));
3147 assert!(msg.contains("VOLCENGINE_API_KEY"));
3148 assert!(msg.contains("VOLCENGINE_ARK_API_KEY"));
3149 assert!(msg.contains("ARK_API_KEY"));
3150 assert!(!msg.contains("DEEPSEEK_SEARCH_API_KEY"));
3151 }
3152
3153 #[tokio::test]
3154 #[allow(clippy::await_holding_lock)]
3155 async fn metaso_provider_without_api_key_fails_closed_before_fallback() {
3156 use crate::config::SearchProvider;
3157 use crate::tools::spec::{ToolContext, ToolSpec};
3158
3159 let _guard = crate::test_support::lock_test_env();
3160 let previous = std::env::var_os("METASO_API_KEY");
3161 unsafe { std::env::remove_var("METASO_API_KEY") };
3162
3163 let tmp = tempfile::tempdir().expect("tempdir");
3164 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3165 ctx.search_provider = SearchProvider::Metaso;
3166 ctx.search_api_key = None;
3167 let error = WebSearchTool
3168 .execute(json!({"query": "anything"}), &ctx)
3169 .await
3170 .expect_err("missing Metaso key must fail before the fallback chain");
3171
3172 match previous {
3173 Some(value) => unsafe { std::env::set_var("METASO_API_KEY", value) },
3174 None => unsafe { std::env::remove_var("METASO_API_KEY") },
3175 }
3176
3177 let message = error.to_string();
3178 assert!(
3179 message.contains("Metaso")
3180 && message.contains("API key")
3181 && message.contains("METASO_API_KEY"),
3182 "got `{message}`"
3183 );
3184 assert!(
3185 !message.contains("duckduckgo"),
3186 "missing configuration must not cross providers: `{message}`"
3187 );
3188 }
3189
3190 #[test]
3191 fn duckduckgo_compatible_url_uses_custom_base_url_and_preserves_query() {
3192 let (url, host) = duckduckgo_search_url(
3193 Some("https://search.internal.example/html/?region=us"),
3194 "rust async",
3195 )
3196 .expect("custom duckduckgo-compatible url");
3197
3198 assert_eq!(host, "search.internal.example");
3199 assert_eq!(
3200 url,
3201 "https://search.internal.example/html/?region=us&q=rust+async"
3202 );
3203 }
3204
3205 #[test]
3206 fn custom_duckduckgo_endpoint_disables_public_bing_fallback() {
3207 assert!(super::duckduckgo_allows_bing_fallback(None));
3208 assert!(super::duckduckgo_allows_bing_fallback(Some(" ")));
3209 assert!(!super::duckduckgo_allows_bing_fallback(Some(
3210 "https://search.internal.example/html/"
3211 )));
3212 }
3213
3214 #[test]
3215 fn searxng_url_uses_search_path_and_json_format() {
3216 let (url, host) =
3217 searxng_search_url(Some("https://search.example/"), "rust async").expect("searxng url");
3218 let parsed = reqwest::Url::parse(&url).expect("valid url");
3219 assert_eq!(host, "search.example");
3220 assert_eq!(parsed.path(), "/search");
3221 assert_eq!(
3222 parsed.query_pairs().find(|(key, _)| key == "q").unwrap().1,
3223 "rust async"
3224 );
3225 assert_eq!(
3226 parsed
3227 .query_pairs()
3228 .find(|(key, _)| key == "format")
3229 .unwrap()
3230 .1,
3231 "json"
3232 );
3233
3234 let (subpath_url, _) = searxng_search_url(
3235 Some("https://search.example/searxng?language=en"),
3236 "codewhale",
3237 )
3238 .expect("searxng subpath url");
3239 let parsed = reqwest::Url::parse(&subpath_url).expect("valid subpath url");
3240 assert_eq!(parsed.path(), "/searxng/search");
3241 assert_eq!(
3242 parsed
3243 .query_pairs()
3244 .find(|(key, _)| key == "language")
3245 .unwrap()
3246 .1,
3247 "en"
3248 );
3249
3250 let (search_url, _) =
3251 searxng_search_url(Some("https://search.example/searxng/search"), "codewhale")
3252 .expect("searxng search endpoint");
3253 assert_eq!(
3254 reqwest::Url::parse(&search_url)
3255 .expect("valid search url")
3256 .path(),
3257 "/searxng/search"
3258 );
3259 }
3260
3261 #[test]
3262 fn searxng_parser_normalizes_results() {
3263 let parsed = json!({
3264 "results": [
3265 {
3266 "title": " Rust async ",
3267 "url": " https://example.com/rust ",
3268 "content": " Result content "
3269 },
3270 {
3271 "title": "Empty snippet",
3272 "url": "https://example.com/empty",
3273 "content": " ",
3274 "snippet": " Fallback snippet "
3275 },
3276 {
3277 "title": "",
3278 "url": "https://example.com/missing-title",
3279 "content": "ignored"
3280 },
3281 {
3282 "title": "Missing URL",
3283 "content": "ignored"
3284 }
3285 ]
3286 });
3287
3288 let results = parse_searxng_results(&parsed, 10);
3289 assert_eq!(results.len(), 2);
3290 assert_eq!(results[0].title, "Rust async");
3291 assert_eq!(results[0].url, "https://example.com/rust");
3292 assert_eq!(results[0].snippet.as_deref(), Some("Result content"));
3293 assert_eq!(results[1].snippet.as_deref(), Some("Fallback snippet"));
3294 }
3295
3296 #[test]
3297 fn searxng_score_reads_floats_integers_strings_and_clamps_junk() {
3298 assert_eq!(searxng_score(&json!({"score": 0.75})), 0.75);
3299 assert_eq!(searxng_score(&json!({"score": 1})), 1.0);
3300 assert_eq!(searxng_score(&json!({"score": " 2.5 "})), 2.5);
3301 assert_eq!(searxng_score(&json!({"score": "-1.5"})), -1.5);
3302 assert_eq!(searxng_score(&json!({})), 0.0);
3303 assert_eq!(searxng_score(&json!({"score": null})), 0.0);
3304 assert_eq!(searxng_score(&json!({"score": true})), 0.0);
3305 assert_eq!(searxng_score(&json!({"score": ""})), 0.0);
3306 assert_eq!(searxng_score(&json!({"score": "not-a-number"})), 0.0);
3307 assert_eq!(searxng_score(&json!({"score": {"nested": 1.0}})), 0.0);
3308 assert_eq!(
3309 searxng_score(&json!({"score": "NaN"})),
3310 0.0,
3311 "a non-finite score must not reach the sort"
3312 );
3313 assert_eq!(
3314 searxng_score(&json!({"score": "inf"})),
3315 0.0,
3316 "an infinite score must not outrank every finite row"
3317 );
3318 }
3319
3320 #[test]
3321 fn searxng_parser_sorts_by_descending_score() {
3322 // The strongest row is last in the instance's own order; only the
3323 // score sort can promote it.
3324 let parsed = json!({
3325 "results": [
3326 {"title": "Low", "url": "https://example.com/low", "score": 0.25},
3327 {"title": "Middle", "url": "https://example.com/mid", "score": 1},
3328 {"title": "High", "url": "https://example.com/high", "score": "4.5"},
3329 {"title": "Zero", "url": "https://example.com/zero", "score": 0.0}
3330 ]
3331 });
3332
3333 let titles: Vec<String> = parse_searxng_results(&parsed, 10)
3334 .into_iter()
3335 .map(|entry| entry.title)
3336 .collect();
3337 assert_eq!(titles, ["High", "Middle", "Low", "Zero"]);
3338 }
3339
3340 #[test]
3341 fn searxng_parser_keeps_input_order_for_equal_scores() {
3342 let parsed = json!({
3343 "results": [
3344 {"title": "First", "url": "https://example.com/1", "score": 1.5},
3345 {"title": "Second", "url": "https://example.com/2", "score": 1.5},
3346 {"title": "Third", "url": "https://example.com/3", "score": 1.5},
3347 {"title": "Lower", "url": "https://example.com/4", "score": 1.4}
3348 ]
3349 });
3350
3351 let titles: Vec<String> = parse_searxng_results(&parsed, 10)
3352 .into_iter()
3353 .map(|entry| entry.title)
3354 .collect();
3355 assert_eq!(titles, ["First", "Second", "Third", "Lower"]);
3356 }
3357
3358 #[test]
3359 fn searxng_parser_sorts_missing_or_invalid_scores_last() {
3360 let parsed = json!({
3361 "results": [
3362 {"title": "No score", "url": "https://example.com/none"},
3363 {
3364 "title": "Garbage",
3365 "url": "https://example.com/garbage",
3366 "score": "not-a-number"
3367 },
3368 {"title": "NaN string", "url": "https://example.com/nan", "score": "NaN"},
3369 {"title": "Infinite string", "url": "https://example.com/inf", "score": "inf"},
3370 {"title": "Boolean", "url": "https://example.com/bool", "score": true},
3371 {"title": "Scored", "url": "https://example.com/scored", "score": 0.5}
3372 ]
3373 });
3374
3375 let results = parse_searxng_results(&parsed, 10);
3376 let titles: Vec<&str> = results.iter().map(|entry| entry.title.as_str()).collect();
3377 // Every row with a title and a URL survives. Unusable scores read as
3378 // 0.0 and keep their input order behind the one scored row.
3379 assert_eq!(
3380 titles,
3381 [
3382 "Scored",
3383 "No score",
3384 "Garbage",
3385 "NaN string",
3386 "Infinite string",
3387 "Boolean"
3388 ]
3389 );
3390 }
3391
3392 #[test]
3393 fn searxng_parser_caps_after_score_sort() {
3394 // A `take` before the sort would drop "Strong"; the cap must apply to
3395 // the ranked list instead.
3396 let parsed = json!({
3397 "results": [
3398 {"title": "Weak one", "url": "https://example.com/1", "score": 0.1},
3399 {"title": "Weak two", "url": "https://example.com/2", "score": 0.2},
3400 {"title": "Strong", "url": "https://example.com/3", "score": 9.0}
3401 ]
3402 });
3403
3404 let results = parse_searxng_results(&parsed, 2);
3405 assert_eq!(results.len(), 2, "max_results caps the ranked list");
3406 assert_eq!(results[0].title, "Strong");
3407 assert_eq!(results[1].title, "Weak two");
3408 }
3409
3410 #[tokio::test]
3411 async fn searxng_provider_requires_base_url() {
3412 use crate::config::SearchProvider;
3413 use crate::tools::spec::{ToolContext, ToolSpec};
3414
3415 let tmp = tempfile::tempdir().expect("tempdir");
3416 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3417 ctx.search_provider = SearchProvider::Searxng;
3418 ctx.search_base_url = None;
3419
3420 let err = WebSearchTool
3421 .execute(json!({"query": "rust async"}), &ctx)
3422 .await
3423 .expect_err("searxng requires explicit base_url");
3424 let msg = err.to_string();
3425 assert!(
3426 matches!(err, crate::tools::spec::ToolError::InvalidInput { .. }),
3427 "missing base_url is a configuration gap, not a transport failure: {err:?}"
3428 );
3429 assert!(
3430 msg.contains("SearXNG")
3431 && msg.contains("base_url")
3432 && msg.contains("no public instance"),
3433 "got `{msg}`"
3434 );
3435 }
3436
3437 #[tokio::test]
3438 #[allow(clippy::await_holding_lock)]
3439 async fn missing_provider_key_fails_closed_as_not_configured() {
3440 use crate::config::SearchProvider;
3441 use crate::tools::spec::{ToolContext, ToolError, ToolSpec};
3442
3443 let _guard = crate::test_support::lock_test_env();
3444 let prev_tavily = std::env::var_os("TAVILY_API_KEY");
3445 // "both keys empty" must mean *both*: an ambient key from the
3446 // operator's shell would otherwise satisfy the Tavily arm.
3447 unsafe { std::env::remove_var("TAVILY_API_KEY") };
3448
3449 for provider in [SearchProvider::Tavily, SearchProvider::Bocha] {
3450 let tmp = tempfile::tempdir().expect("tempdir");
3451 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3452 ctx.search_provider = provider;
3453 ctx.search_api_key = None;
3454
3455 let error = WebSearchTool
3456 .execute(json!({"query": "needs configuration"}), &ctx)
3457 .await
3458 .expect_err("a keyed provider without an API key must fail closed");
3459 assert!(
3460 matches!(error, ToolError::InvalidInput { .. }),
3461 "config gaps must stay distinguishable from transport failures: {error:?}"
3462 );
3463 let message = error.to_string();
3464 assert!(message.contains("is not configured"), "got `{message}`");
3465 assert!(message.contains("api_key"), "got `{message}`");
3466 }
3467
3468 // Sibling case: only `TAVILY_API_KEY` is set. Explicit Tavily is
3469 // configured, and the copy that names both sources is the one the
3470 // operator never sees here.
3471 unsafe { std::env::set_var("TAVILY_API_KEY", "tvly-test-env-only") };
3472 let tmp = tempfile::tempdir().expect("tempdir");
3473 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3474 ctx.search_provider = SearchProvider::Tavily;
3475 ctx.search_api_key = None;
3476 let preflight = super::preflight_search_provider(&ctx);
3477
3478 match prev_tavily {
3479 Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
3480 None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
3481 }
3482
3483 assert!(
3484 preflight.is_ok(),
3485 "TAVILY_API_KEY alone must configure explicit Tavily: {preflight:?}"
3486 );
3487 }
3488
3489 #[test]
3490 fn tavily_key_from_prefers_dedicated_env_and_prefix_gates_only_the_generic_key() {
3491 let _guard = crate::test_support::lock_test_env();
3492 let prev = std::env::var_os("TAVILY_API_KEY");
3493
3494 unsafe { std::env::set_var("TAVILY_API_KEY", "tvly-a") };
3495 assert_eq!(
3496 crate::config::tavily_key_from(Some("tvly-b")).as_deref(),
3497 Some("tvly-a"),
3498 "the dedicated env wins over the shared generic slot"
3499 );
3500 assert_eq!(crate::config::tavily_env_key().as_deref(), Some("tvly-a"));
3501
3502 // A dedicated env key is never prefix-checked.
3503 unsafe { std::env::set_var("TAVILY_API_KEY", "not-a-tvly-prefix") };
3504 assert_eq!(
3505 crate::config::tavily_key_from(None).as_deref(),
3506 Some("not-a-tvly-prefix")
3507 );
3508
3509 unsafe { std::env::set_var("TAVILY_API_KEY", " ") };
3510 assert_eq!(crate::config::tavily_env_key(), None);
3511
3512 unsafe { std::env::remove_var("TAVILY_API_KEY") };
3513 assert_eq!(
3514 crate::config::tavily_key_from(Some("tvly-b")).as_deref(),
3515 Some("tvly-b")
3516 );
3517 assert_eq!(
3518 crate::config::tavily_key_from(Some("doctor-offline-search-sentinel")),
3519 None,
3520 "a non-`tvly-` generic key must never autodetect Tavily"
3521 );
3522 assert_eq!(crate::config::tavily_key_from(Some(" ")), None);
3523 assert!(crate::config::looks_like_tavily_key(" tvly-x "));
3524 assert!(!crate::config::looks_like_tavily_key("fc-live-test"));
3525
3526 match prev {
3527 Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
3528 None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
3529 }
3530 }
3531
3532 #[tokio::test]
3533 async fn searxng_search_returns_json_results() {
3534 use crate::config::SearchProvider;
3535 use crate::tools::spec::{ToolContext, ToolSpec};
3536 use wiremock::matchers::{method, path, query_param};
3537 use wiremock::{Mock, MockServer, ResponseTemplate};
3538
3539 let server = MockServer::start().await;
3540 Mock::given(method("GET"))
3541 .and(path("/search"))
3542 .and(query_param("q", "rust async"))
3543 .and(query_param("format", "json"))
3544 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3545 "results": [
3546 {
3547 "title": "Rust async",
3548 "url": "https://example.com/rust",
3549 "content": "Async Rust result"
3550 }
3551 ]
3552 })))
3553 .mount(&server)
3554 .await;
3555
3556 let tmp = tempfile::tempdir().expect("tempdir");
3557 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3558 ctx.search_provider = SearchProvider::Searxng;
3559 ctx.search_base_url = Some(server.uri());
3560
3561 let result = WebSearchTool
3562 .execute(json!({"query": "rust async"}), &ctx)
3563 .await
3564 .expect("searxng endpoint should return results");
3565 let value: serde_json::Value =
3566 serde_json::from_str(&result.content).expect("web search json response");
3567
3568 assert_eq!(value["source"].as_str(), Some("searxng"));
3569 assert_eq!(value["count"].as_u64(), Some(1));
3570 assert_eq!(value["results"][0]["rank"].as_u64(), Some(1));
3571 assert_eq!(value["results"][0]["domain"], "example.com");
3572 assert_eq!(value["receipt"]["backend"], "searxng");
3573 assert_eq!(
3574 value["receipt"]["backend_detail"].as_str(),
3575 Some("127.0.0.1")
3576 );
3577 assert!(
3578 value["message"]
3579 .as_str()
3580 .expect("message")
3581 .contains("Backend: searxng at")
3582 );
3583 }
3584
3585 #[tokio::test]
3586 async fn unsupported_knobs_are_visible_and_domains_are_post_filtered() {
3587 use crate::config::SearchProvider;
3588 use crate::tools::spec::{ToolContext, ToolSpec};
3589 use wiremock::matchers::{method, path, query_param};
3590 use wiremock::{Mock, MockServer, ResponseTemplate};
3591
3592 let server = MockServer::start().await;
3593 Mock::given(method("GET"))
3594 .and(path("/search"))
3595 .and(query_param("q", "fresh rust"))
3596 .and(query_param("format", "json"))
3597 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3598 "results": [
3599 {"title": "Keep", "url": "https://docs.example.com/rust", "content": "kept"},
3600 {"title": "Drop", "url": "https://other.test/rust", "content": "dropped"}
3601 ]
3602 })))
3603 .mount(&server)
3604 .await;
3605
3606 let tmp = tempfile::tempdir().expect("tempdir");
3607 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3608 ctx.search_provider = SearchProvider::Searxng;
3609 ctx.search_base_url = Some(server.uri());
3610
3611 let result = WebSearchTool
3612 .execute(
3613 json!({
3614 "query": "fresh rust",
3615 "recency": "week",
3616 "domains": ["example.com"],
3617 "locale": "en-US"
3618 }),
3619 &ctx,
3620 )
3621 .await
3622 .expect("structured query should execute");
3623 let value: serde_json::Value =
3624 serde_json::from_str(&result.content).expect("web search json response");
3625
3626 assert_eq!(value["count"], 1);
3627 assert_eq!(value["results"][0]["domain"], "docs.example.com");
3628 assert_eq!(value["receipt"]["honored"]["domains"], true);
3629 assert_eq!(value["receipt"]["honored"]["recency"], false);
3630 assert_eq!(value["receipt"]["honored"]["locale"], false);
3631 let degraded = value["receipt"]["degraded"]
3632 .as_array()
3633 .expect("degraded receipt array");
3634 assert!(
3635 degraded
3636 .iter()
3637 .any(|item| { item["kind"] == "post_filtered" && item["knob"] == "domains" })
3638 );
3639 assert!(
3640 degraded
3641 .iter()
3642 .any(|item| { item["kind"] == "knob_ignored" && item["knob"] == "recency" })
3643 );
3644 assert!(
3645 degraded
3646 .iter()
3647 .any(|item| { item["kind"] == "knob_ignored" && item["knob"] == "locale" })
3648 );
3649 }
3650
3651 #[test]
3652 fn provider_native_domain_filter_is_reported_as_provider_honored() {
3653 let query = SearchQuery::new(
3654 "current release".to_string(),
3655 3,
3656 Some(Recency::Week),
3657 vec!["example.com".to_string()],
3658 None,
3659 );
3660 let raw = BackendSearch {
3661 backend: BackendId::ProviderNative,
3662 source: "provider-native/xai/grok-4.5".to_string(),
3663 backend_detail: Some("api.x.ai".to_string()),
3664 results: vec![SearchResult::new(
3665 1,
3666 "Exact source".to_string(),
3667 "https://docs.example.com/release".to_string(),
3668 None,
3669 None,
3670 )],
3671 degraded: Vec::new(),
3672 note: Some("Grounded answer.".to_string()),
3673 };
3674 let response = finalize_search_response(
3675 query,
3676 QueryCapabilities {
3677 max_results: CapabilityState::Supported,
3678 recency: CapabilityState::Unsupported,
3679 domains: CapabilityState::Supported,
3680 locale: CapabilityState::Unsupported,
3681 published_date: CapabilityState::Unknown,
3682 },
3683 raw,
3684 Instant::now(),
3685 );
3686
3687 assert!(response.receipt.honored.max_results);
3688 assert!(response.receipt.honored.domains);
3689 assert!(!response.receipt.honored.recency);
3690 assert!(response.receipt.degraded.iter().any(|reason| matches!(
3691 reason,
3692 DegradedReason::KnobIgnored {
3693 knob: QueryKnob::Recency
3694 }
3695 )));
3696 assert!(!response.receipt.degraded.iter().any(|reason| matches!(
3697 reason,
3698 DegradedReason::PostFiltered {
3699 knob: QueryKnob::Domains
3700 }
3701 )));
3702 assert!(response.message.contains("Grounded answer."));
3703 }
3704
3705 #[test]
3706 fn provider_native_discards_answer_when_domain_filter_removes_a_source() {
3707 let query = SearchQuery::new(
3708 "current release".to_string(),
3709 3,
3710 None,
3711 vec!["example.com".to_string()],
3712 None,
3713 );
3714 let raw = BackendSearch {
3715 backend: BackendId::ProviderNative,
3716 source: "provider-native/xai/grok-4.5".to_string(),
3717 backend_detail: Some("api.x.ai".to_string()),
3718 results: vec![
3719 SearchResult::new(
3720 1,
3721 "Allowed source".to_string(),
3722 "https://docs.example.com/release".to_string(),
3723 None,
3724 None,
3725 ),
3726 SearchResult::new(
3727 2,
3728 "Leaked source".to_string(),
3729 "https://outside.test/release".to_string(),
3730 None,
3731 None,
3732 ),
3733 ],
3734 degraded: Vec::new(),
3735 note: Some("Answer synthesized from both sources.".to_string()),
3736 };
3737 let response = finalize_search_response(
3738 query,
3739 QueryCapabilities {
3740 max_results: CapabilityState::Supported,
3741 recency: CapabilityState::Unsupported,
3742 domains: CapabilityState::Supported,
3743 locale: CapabilityState::Unsupported,
3744 published_date: CapabilityState::Unknown,
3745 },
3746 raw,
3747 Instant::now(),
3748 );
3749
3750 assert_eq!(response.count, 1);
3751 assert_eq!(response.results[0].domain, "docs.example.com");
3752 assert_eq!(response.message, "Found 1 result(s)");
3753 assert!(response.receipt.degraded.iter().any(|reason| matches!(
3754 reason,
3755 DegradedReason::PostFiltered {
3756 knob: QueryKnob::Domains
3757 }
3758 )));
3759 }
3760
3761 #[test]
3762 fn search_results_receive_session_scoped_refs_and_sanitize_credential_urls() {
3763 let query = SearchQuery::new("sources".to_string(), 5, None, Vec::new(), None);
3764 let raw = BackendSearch {
3765 backend: BackendId::DuckDuckGo,
3766 source: "duckduckgo".to_string(),
3767 backend_detail: None,
3768 results: vec![
3769 SearchResult::new(
3770 1,
3771 "Valid".to_string(),
3772 "https://example.com/source#section".to_string(),
3773 None,
3774 None,
3775 ),
3776 SearchResult::new(
3777 2,
3778 "Protected".to_string(),
3779 "https://example.com/protected?access_token=sensitive&view=full".to_string(),
3780 None,
3781 None,
3782 ),
3783 ],
3784 degraded: Vec::new(),
3785 note: None,
3786 };
3787 let mut response =
3788 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
3789 let context = crate::tools::spec::ToolContext::new(std::path::PathBuf::from("."))
3790 .with_state_namespace("search-citation-session");
3791
3792 register_search_citations(&mut response, &context);
3793
3794 assert_eq!(response.count, 2);
3795 assert_eq!(response.results[0].url, "https://example.com/source");
3796 assert_eq!(
3797 response.results[1].url,
3798 "https://example.com/protected?view=full"
3799 );
3800 assert!(!response.results[1].url.contains("sensitive"));
3801 assert!(response.results[0].ref_id.starts_with("web_"));
3802 assert!(
3803 crate::tools::web::citations::resolve(
3804 "search-citation-session",
3805 &response.results[0].ref_id
3806 )
3807 .is_some()
3808 );
3809 assert!(
3810 crate::tools::web::citations::resolve(
3811 "foreign-search-citation-session",
3812 &response.results[0].ref_id
3813 )
3814 .is_none()
3815 );
3816 }
3817
3818 #[tokio::test]
3819 async fn searxng_empty_results_report_backend() {
3820 use crate::config::SearchProvider;
3821 use crate::tools::spec::ToolContext;
3822 use wiremock::matchers::{method, path, query_param};
3823 use wiremock::{Mock, MockServer, ResponseTemplate};
3824
3825 let server = MockServer::start().await;
3826 Mock::given(method("GET"))
3827 .and(path("/search"))
3828 .and(query_param("q", "empty"))
3829 .and(query_param("format", "json"))
3830 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results": []})))
3831 .mount(&server)
3832 .await;
3833
3834 let tmp = tempfile::tempdir().expect("tempdir");
3835 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3836 ctx.search_provider = SearchProvider::Searxng;
3837 ctx.search_base_url = Some(server.uri());
3838
3839 let (results, host) = WebSearchTool
3840 .run_searxng_search("empty", 5, 5_000, &ctx)
3841 .await
3842 .expect("empty SearXNG adapter response should be successful");
3843 let expected_host = reqwest::Url::parse(&server.uri())
3844 .expect("mock URL")
3845 .host_str()
3846 .expect("mock host")
3847 .to_string();
3848
3849 assert!(results.is_empty());
3850 assert_eq!(host, expected_host);
3851 }
3852
3853 #[tokio::test]
3854 async fn searxng_http_errors_are_actionable() {
3855 use crate::config::SearchProvider;
3856 use crate::tools::spec::ToolContext;
3857 use wiremock::matchers::{method, path, query_param};
3858 use wiremock::{Mock, MockServer, ResponseTemplate};
3859
3860 let server = MockServer::start().await;
3861 Mock::given(method("GET"))
3862 .and(path("/search"))
3863 .and(query_param("q", "blocked"))
3864 .and(query_param("format", "json"))
3865 .respond_with(ResponseTemplate::new(403).set_body_string("json disabled"))
3866 .mount(&server)
3867 .await;
3868
3869 let tmp = tempfile::tempdir().expect("tempdir");
3870 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3871 ctx.search_provider = SearchProvider::Searxng;
3872 ctx.search_base_url = Some(server.uri());
3873
3874 let err = WebSearchTool
3875 .run_searxng_search("blocked", 5, 5_000, &ctx)
3876 .await
3877 .expect_err("403 should be actionable");
3878 let msg = err.to_string();
3879 assert!(
3880 msg.contains("HTTP 403")
3881 && msg.contains("JSON output")
3882 && msg.contains("permits API access"),
3883 "got `{msg}`"
3884 );
3885 }
3886
3887 #[tokio::test]
3888 async fn searxng_rate_limit_error_mentions_configured_instance() {
3889 use crate::config::SearchProvider;
3890 use crate::tools::spec::ToolContext;
3891 use wiremock::matchers::{method, path, query_param};
3892 use wiremock::{Mock, MockServer, ResponseTemplate};
3893
3894 let server = MockServer::start().await;
3895 Mock::given(method("GET"))
3896 .and(path("/search"))
3897 .and(query_param("q", "later"))
3898 .and(query_param("format", "json"))
3899 .respond_with(ResponseTemplate::new(429).set_body_string("too many requests"))
3900 .mount(&server)
3901 .await;
3902
3903 let tmp = tempfile::tempdir().expect("tempdir");
3904 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3905 ctx.search_provider = SearchProvider::Searxng;
3906 ctx.search_base_url = Some(server.uri());
3907
3908 let err = WebSearchTool
3909 .run_searxng_search("later", 5, 5_000, &ctx)
3910 .await
3911 .expect_err("429 should be actionable");
3912 let msg = err.to_string();
3913 assert!(
3914 msg.contains("HTTP 429")
3915 && msg.contains("rate-limiting")
3916 && msg.contains("trusted/self-hosted instance"),
3917 "got `{msg}`"
3918 );
3919 }
3920
3921 #[tokio::test]
3922 async fn searxng_invalid_json_is_actionable() {
3923 use crate::config::SearchProvider;
3924 use crate::tools::spec::ToolContext;
3925 use wiremock::matchers::{method, path, query_param};
3926 use wiremock::{Mock, MockServer, ResponseTemplate};
3927
3928 let server = MockServer::start().await;
3929 Mock::given(method("GET"))
3930 .and(path("/search"))
3931 .and(query_param("q", "html"))
3932 .and(query_param("format", "json"))
3933 .respond_with(ResponseTemplate::new(200).set_body_string("<html>not json</html>"))
3934 .mount(&server)
3935 .await;
3936
3937 let tmp = tempfile::tempdir().expect("tempdir");
3938 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3939 ctx.search_provider = SearchProvider::Searxng;
3940 ctx.search_base_url = Some(server.uri());
3941
3942 let err = WebSearchTool
3943 .run_searxng_search("html", 5, 5_000, &ctx)
3944 .await
3945 .expect_err("invalid JSON should be actionable");
3946 let msg = err.to_string();
3947 assert!(
3948 msg.contains("Failed to parse SearXNG JSON response")
3949 && msg.contains("format=json")
3950 && msg.contains("JSON output"),
3951 "got `{msg}`"
3952 );
3953 }
3954
3955 #[tokio::test]
3956 async fn custom_duckduckgo_results_report_custom_host_source() {
3957 use crate::config::SearchProvider;
3958 use crate::tools::spec::{ToolContext, ToolSpec};
3959 use wiremock::matchers::{method, path, query_param};
3960 use wiremock::{Mock, MockServer, ResponseTemplate};
3961
3962 let server = MockServer::start().await;
3963 Mock::given(method("GET"))
3964 .and(path("/html/"))
3965 .and(query_param("q", "rust async"))
3966 .respond_with(ResponseTemplate::new(200).set_body_string(
3967 r#"
3968 <html><body>
3969 <a class="result__a" href="https://example.com/rust">Rust async</a>
3970 <div class="result__snippet">Async Rust result</div>
3971 </body></html>
3972 "#,
3973 ))
3974 .mount(&server)
3975 .await;
3976
3977 let tmp = tempfile::tempdir().expect("tempdir");
3978 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
3979 ctx.search_provider = SearchProvider::DuckDuckGo;
3980 let base_url = format!("{}/html/", server.uri());
3981 let expected_host = reqwest::Url::parse(&base_url)
3982 .expect("mock server url")
3983 .host_str()
3984 .expect("mock server host")
3985 .to_string();
3986 ctx.search_base_url = Some(base_url);
3987
3988 let result = WebSearchTool
3989 .execute(json!({"query": "rust async"}), &ctx)
3990 .await
3991 .expect("custom endpoint should return results");
3992 let value: serde_json::Value =
3993 serde_json::from_str(&result.content).expect("web search json response");
3994
3995 assert_eq!(value["source"].as_str(), Some(expected_host.as_str()));
3996 assert_eq!(value["count"].as_u64(), Some(1));
3997 }
3998
3999 #[tokio::test]
4000 async fn repeated_search_uses_session_cache_and_marks_receipt() {
4001 use crate::config::SearchProvider;
4002 use crate::tools::spec::{ToolContext, ToolSpec};
4003 use crate::tools::web::cache;
4004 use wiremock::matchers::{method, path, query_param};
4005 use wiremock::{Mock, MockServer, ResponseTemplate};
4006
4007 cache::reset_search();
4008 let server = MockServer::start().await;
4009 Mock::given(method("GET"))
4010 .and(path("/html/"))
4011 .and(query_param("q", "session cache receipt"))
4012 .respond_with(ResponseTemplate::new(200).set_body_string(
4013 r#"
4014 <html><body>
4015 <a class="result__a" href="https://example.com/cached">Cached result</a>
4016 <div class="result__snippet">Fetched once.</div>
4017 </body></html>
4018 "#,
4019 ))
4020 .mount(&server)
4021 .await;
4022
4023 let tmp = tempfile::tempdir().expect("tempdir");
4024 let mut context = ToolContext::new(tmp.path().to_path_buf())
4025 .with_state_namespace("web-search-query-cache");
4026 context.search_provider = SearchProvider::DuckDuckGo;
4027 context.search_base_url = Some(format!("{}/html/", server.uri()));
4028
4029 let first = WebSearchTool
4030 .execute(json!({"query": "session cache receipt"}), &context)
4031 .await
4032 .expect("first search should succeed");
4033 let second = WebSearchTool
4034 .execute(json!({"query": "session cache receipt"}), &context)
4035 .await
4036 .expect("second search should hit cache");
4037 let first: serde_json::Value =
4038 serde_json::from_str(&first.content).expect("first response json");
4039 let second: serde_json::Value =
4040 serde_json::from_str(&second.content).expect("second response json");
4041 let requests = server.received_requests().await.expect("recorded requests");
4042
4043 assert_eq!(requests.len(), 1);
4044 assert_eq!(first["receipt"]["cache_hit"], false);
4045 assert_eq!(second["receipt"]["cache_hit"], true);
4046 assert_eq!(second["receipt"]["latency_ms"], 0);
4047 assert_eq!(second["results"], first["results"]);
4048
4049 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
4050 let denied_host = reqwest::Url::parse(&server.uri())
4051 .expect("mock server URL")
4052 .host_str()
4053 .expect("mock server host")
4054 .to_string();
4055 let policy = NetworkPolicy {
4056 default: Decision::Allow.into(),
4057 allow: Vec::new(),
4058 deny: vec![denied_host],
4059 proxy: Vec::new(),
4060 proxy_fake_ip_cidrs: Vec::new(),
4061 audit: false,
4062 };
4063 let blocked = context
4064 .clone()
4065 .with_network_policy(NetworkPolicyDecider::new(policy, None));
4066 let error = WebSearchTool
4067 .execute(json!({"query": "session cache receipt"}), &blocked)
4068 .await
4069 .expect_err("tightened policy must win over the query cache");
4070 assert!(error.to_string().contains("blocked by network policy"));
4071 assert_eq!(
4072 server
4073 .received_requests()
4074 .await
4075 .expect("recorded requests")
4076 .len(),
4077 1
4078 );
4079 }
4080
4081 #[tokio::test]
4082 async fn explicit_bing_does_not_fall_back_to_duckduckgo() {
4083 use crate::config::SearchProvider;
4084 use crate::tools::spec::ToolContext;
4085 use wiremock::matchers::{method, path, query_param};
4086 use wiremock::{Mock, MockServer, ResponseTemplate};
4087
4088 let server = MockServer::start().await;
4089 Mock::given(method("GET"))
4090 .and(path("/bing"))
4091 .and(query_param("q", "one way fallback"))
4092 .respond_with(ResponseTemplate::new(200).set_body_string("<html></html>"))
4093 .mount(&server)
4094 .await;
4095
4096 let tmp = tempfile::tempdir().expect("tempdir");
4097 let mut context = ToolContext::new(tmp.path().to_path_buf());
4098 context.search_provider = SearchProvider::Bing;
4099 context.search_base_url = Some(format!("{}/must-not-be-used", server.uri()));
4100 let query = SearchQuery::new("one way fallback".to_string(), 5, None, Vec::new(), None);
4101 let raw = run_scrape_search_with_endpoints(
4102 SearchProvider::Bing,
4103 &query,
4104 5_000,
4105 &context,
4106 ScrapeEndpoints {
4107 bing: &format!("{}/bing", server.uri()),
4108 allow_bing_fallback: Some(true),
4109 },
4110 )
4111 .await
4112 .expect("empty Bing response is a successful empty search");
4113 let requests = server.received_requests().await.expect("recorded requests");
4114
4115 assert_eq!(raw.backend, BackendId::Bing);
4116 assert!(raw.results.is_empty());
4117 assert!(raw.degraded.is_empty());
4118 assert_eq!(requests.len(), 1);
4119 assert_eq!(requests[0].url.path(), "/bing");
4120 }
4121
4122 #[tokio::test]
4123 async fn custom_duckduckgo_challenge_returns_actionable_error() {
4124 use crate::config::SearchProvider;
4125 use crate::tools::spec::{ToolContext, ToolSpec};
4126 use wiremock::matchers::{method, path, query_param};
4127 use wiremock::{Mock, MockServer, ResponseTemplate};
4128
4129 let server = MockServer::start().await;
4130 Mock::given(method("GET"))
4131 .and(path("/html/"))
4132 .and(query_param("q", "rust async"))
4133 .respond_with(ResponseTemplate::new(200).set_body_string(
4134 r#"<html><body><div class="anomaly-modal">Unfortunately, bots use DuckDuckGo too</div></body></html>"#,
4135 ))
4136 .mount(&server)
4137 .await;
4138
4139 let tmp = tempfile::tempdir().expect("tempdir");
4140 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4141 ctx.search_provider = SearchProvider::DuckDuckGo;
4142 ctx.search_base_url = Some(format!("{}/html/", server.uri()));
4143
4144 let err = WebSearchTool
4145 .execute(json!({"query": "rust async"}), &ctx)
4146 .await
4147 .expect_err("custom endpoint challenge should error");
4148 let msg = err.to_string();
4149 assert!(
4150 msg.contains("DuckDuckGo-compatible search endpoint")
4151 && msg.contains("bot challenge")
4152 && msg.contains("private search service"),
4153 "got `{msg}`"
4154 );
4155 }
4156
4157 #[tokio::test]
4158 async fn duckduckgo_challenge_to_bing_success_populates_fallback_receipt() {
4159 use crate::config::SearchProvider;
4160 use crate::tools::spec::ToolContext;
4161 use std::time::Instant;
4162 use wiremock::matchers::{method, path, query_param};
4163 use wiremock::{Mock, MockServer, ResponseTemplate};
4164
4165 let server = MockServer::start().await;
4166 Mock::given(method("GET"))
4167 .and(path("/html/"))
4168 .and(query_param("q", "fallback receipt"))
4169 .respond_with(ResponseTemplate::new(200).set_body_string(
4170 r#"<html><body><div class="anomaly-modal">Unfortunately, bots use DuckDuckGo too</div></body></html>"#,
4171 ))
4172 .mount(&server)
4173 .await;
4174 Mock::given(method("GET"))
4175 .and(path("/bing"))
4176 .and(query_param("q", "fallback receipt"))
4177 .respond_with(ResponseTemplate::new(200).set_body_string(
4178 r#"
4179 <ol><li class="b_algo">
4180 <h2><a href="https://example.com/fallback">Fallback result</a></h2>
4181 <div class="b_caption"><p>Bing result after challenge.</p></div>
4182 </li></ol>
4183 "#,
4184 ))
4185 .mount(&server)
4186 .await;
4187
4188 let tmp = tempfile::tempdir().expect("tempdir");
4189 let mut context = ToolContext::new(tmp.path().to_path_buf());
4190 context.search_provider = SearchProvider::DuckDuckGo;
4191 context.search_base_url = Some(format!("{}/html/", server.uri()));
4192 let query = SearchQuery::new("fallback receipt".to_string(), 5, None, Vec::new(), None);
4193 let started = Instant::now();
4194 let raw = run_scrape_search_with_endpoints(
4195 SearchProvider::DuckDuckGo,
4196 &query,
4197 5_000,
4198 &context,
4199 ScrapeEndpoints {
4200 bing: &format!("{}/bing", server.uri()),
4201 allow_bing_fallback: Some(true),
4202 },
4203 )
4204 .await
4205 .expect("Bing fallback should succeed");
4206 let response =
4207 finalize_search_response(query, QueryCapabilities::count_only(), raw, started);
4208 let value = serde_json::to_value(&response).expect("response serializes");
4209
4210 assert_eq!(value["source"], "bing");
4211 assert_eq!(value["count"], 1);
4212 assert_eq!(value["receipt"]["backend"], "bing");
4213 assert_eq!(
4214 value["receipt"]["degraded"][0],
4215 json!({"kind": "challenge_detected", "backend": "duckduckgo"})
4216 );
4217 assert_eq!(
4218 value["receipt"]["degraded"][1],
4219 json!({"kind": "scrape_fallback", "from": "duckduckgo", "to": "bing"})
4220 );
4221 assert!(
4222 response
4223 .receipt
4224 .warning()
4225 .expect("warning")
4226 .contains("used bing fallback")
4227 );
4228 }
4229
4230 #[tokio::test]
4231 async fn search_base_url_with_non_duckduckgo_provider_is_explicit_error() {
4232 use crate::config::SearchProvider;
4233 use crate::tools::spec::{ToolContext, ToolSpec};
4234
4235 let tmp = tempfile::tempdir().expect("tempdir");
4236 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4237 ctx.search_provider = SearchProvider::Tavily;
4238 ctx.search_base_url = Some("https://search.internal.example/html/".to_string());
4239
4240 let err = WebSearchTool
4241 .execute(json!({"query": "rust async"}), &ctx)
4242 .await
4243 .expect_err("non-duckduckgo provider with base_url should error");
4244 let msg = err.to_string();
4245 assert!(
4246 msg.contains("[search].base_url")
4247 && msg.contains("provider = \"duckduckgo\" or \"searxng\"")
4248 && msg.contains("tavily"),
4249 "got `{msg}`"
4250 );
4251 }
4252
4253 #[test]
4254 fn rerank_assigns_sequential_ranks_starting_at_one() {
4255 // Simulates the post-dedup path: ranks may be non-contiguous after a
4256 // result is dropped; rerank must restore a clean 1..N sequence.
4257 let mut results = vec![
4258 SearchResult::new(
4259 5,
4260 "C".to_string(),
4261 "https://c.example.com/".to_string(),
4262 None,
4263 None,
4264 ),
4265 SearchResult::new(
4266 3,
4267 "A".to_string(),
4268 "https://a.example.com/".to_string(),
4269 None,
4270 None,
4271 ),
4272 SearchResult::new(
4273 1,
4274 "B".to_string(),
4275 "https://b.example.com/".to_string(),
4276 None,
4277 None,
4278 ),
4279 ];
4280 rerank(&mut results);
4281 assert_eq!(results[0].rank, 1);
4282 assert_eq!(results[1].rank, 2);
4283 assert_eq!(results[2].rank, 3);
4284 }
4285
4286 #[test]
4287 fn rerank_on_empty_slice_is_a_no_op() {
4288 let mut results: Vec<SearchResult> = Vec::new();
4289 rerank(&mut results); // must not panic
4290 }
4291
4292 #[test]
4293 fn register_search_citations_deduplicates_results_with_same_canonical_url() {
4294 let namespace = "dedup-test-session-fragments";
4295 let query = SearchQuery::new("deduplicate".to_string(), 5, None, Vec::new(), None);
4296 let raw = BackendSearch {
4297 backend: BackendId::DuckDuckGo,
4298 source: "duckduckgo".to_string(),
4299 backend_detail: None,
4300 results: vec![
4301 SearchResult::new(
4302 1,
4303 "First occurrence".to_string(),
4304 "https://dedup.example.com/page#section-a".to_string(),
4305 Some("first snippet".to_string()),
4306 None,
4307 ),
4308 SearchResult::new(
4309 2,
4310 "Unique result".to_string(),
4311 "https://other.dedup.example.com/different".to_string(),
4312 None,
4313 None,
4314 ),
4315 SearchResult::new(
4316 3,
4317 "Duplicate of first".to_string(),
4318 "https://dedup.example.com/page#section-b".to_string(),
4319 Some("duplicate snippet".to_string()),
4320 None,
4321 ),
4322 ],
4323 degraded: Vec::new(),
4324 note: None,
4325 };
4326 let mut response =
4327 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
4328 let context = crate::tools::spec::ToolContext::new(std::path::PathBuf::from("."))
4329 .with_state_namespace(namespace);
4330
4331 register_search_citations(&mut response, &context);
4332
4333 assert_eq!(
4334 response.count, 2,
4335 "duplicate canonical URL must reduce the result count"
4336 );
4337 assert_eq!(response.results.len(), 2);
4338 assert_eq!(response.results[0].rank, 1);
4339 assert_eq!(response.results[1].rank, 2);
4340 assert_eq!(response.results[0].url, "https://dedup.example.com/page");
4341 assert_eq!(
4342 response.results[1].url,
4343 "https://other.dedup.example.com/different"
4344 );
4345 assert_ne!(
4346 response.results[0].ref_id, response.results[1].ref_id,
4347 "surviving results must have distinct ref_ids"
4348 );
4349 assert!(response.message.contains('2'), "{}", response.message);
4350 }
4351
4352 #[test]
4353 fn register_search_citations_preserves_title_url_and_ref_id_metadata() {
4354 let namespace = "citation-metadata-test-session";
4355 let query = SearchQuery::new("docs".to_string(), 5, None, Vec::new(), None);
4356 let raw = BackendSearch {
4357 backend: BackendId::DuckDuckGo,
4358 source: "duckduckgo".to_string(),
4359 backend_detail: None,
4360 results: vec![SearchResult::new(
4361 1,
4362 "Official Docs".to_string(),
4363 "https://docs.citation-meta.example.com/reference".to_string(),
4364 Some("Comprehensive reference documentation.".to_string()),
4365 None,
4366 )],
4367 degraded: Vec::new(),
4368 note: None,
4369 };
4370 let mut response =
4371 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
4372 let context = crate::tools::spec::ToolContext::new(std::path::PathBuf::from("."))
4373 .with_state_namespace(namespace);
4374
4375 register_search_citations(&mut response, &context);
4376
4377 assert_eq!(response.count, 1);
4378 let result = &response.results[0];
4379 assert!(
4380 result.ref_id.starts_with("web_"),
4381 "ref_id must use web_ prefix; got `{}`",
4382 result.ref_id
4383 );
4384 assert_eq!(result.title, "Official Docs");
4385 assert_eq!(
4386 result.url,
4387 "https://docs.citation-meta.example.com/reference"
4388 );
4389 assert_eq!(result.rank, 1);
4390 let citation = crate::tools::web::citations::resolve(namespace, &result.ref_id)
4391 .expect("citation must be registered and resolvable in its session");
4392 assert_eq!(citation.ref_id, result.ref_id);
4393 assert_eq!(citation.url, result.url);
4394 assert_eq!(citation.title.as_deref(), Some("Official Docs"));
4395 assert!(
4396 !citation.retrieved_at.is_empty(),
4397 "retrieved_at must be set to the retrieval timestamp"
4398 );
4399 assert!(
4400 crate::tools::web::citations::resolve("other-session", &result.ref_id).is_none(),
4401 "citation must not leak to foreign sessions"
4402 );
4403 }
4404
4405 #[test]
4406 fn finalize_search_response_truncates_to_max_results_and_reranks() {
4407 let query = SearchQuery::new("truncate me".to_string(), 2, None, Vec::new(), None);
4408 let raw = BackendSearch {
4409 backend: BackendId::DuckDuckGo,
4410 source: "duckduckgo".to_string(),
4411 backend_detail: None,
4412 results: vec![
4413 SearchResult::new(
4414 1,
4415 "A".to_string(),
4416 "https://a.trunc.example.com/".to_string(),
4417 None,
4418 None,
4419 ),
4420 SearchResult::new(
4421 2,
4422 "B".to_string(),
4423 "https://b.trunc.example.com/".to_string(),
4424 None,
4425 None,
4426 ),
4427 SearchResult::new(
4428 3,
4429 "C".to_string(),
4430 "https://c.trunc.example.com/".to_string(),
4431 None,
4432 None,
4433 ),
4434 ],
4435 degraded: Vec::new(),
4436 note: None,
4437 };
4438
4439 let response =
4440 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
4441
4442 assert_eq!(response.count, 2, "must be truncated to max_results");
4443 assert_eq!(response.results.len(), 2);
4444 assert_eq!(response.results[0].rank, 1);
4445 assert_eq!(response.results[1].rank, 2);
4446 assert_eq!(response.results[0].title, "A");
4447 assert_eq!(response.results[1].title, "B");
4448 assert!(response.message.contains('2'), "{}", response.message);
4449 }
4450
4451 #[test]
4452 fn domain_matches_handles_subdomains_www_prefix_and_empty_list() {
4453 assert!(
4454 domain_matches("https://any.example.com/page", &[]),
4455 "empty domain list must accept all URLs"
4456 );
4457 assert!(domain_matches(
4458 "https://example.com/page",
4459 &["example.com".to_string()]
4460 ));
4461 assert!(domain_matches(
4462 "https://docs.example.com/page",
4463 &["example.com".to_string()]
4464 ));
4465 assert!(domain_matches(
4466 "https://www.example.com/page",
4467 &["example.com".to_string()]
4468 ));
4469 assert!(domain_matches(
4470 "https://example.com/page",
4471 &["www.example.com".to_string()]
4472 ));
4473 assert!(!domain_matches(
4474 "https://other.com/page",
4475 &["example.com".to_string()]
4476 ));
4477 assert!(!domain_matches(
4478 "https://notexample.com/page",
4479 &["example.com".to_string()]
4480 ));
4481 }
4482
4483 #[test]
4484 fn finalize_search_response_domain_post_filter_reranks_survivors() {
4485 let query = SearchQuery::new(
4486 "domain filter".to_string(),
4487 5,
4488 None,
4489 vec!["keep.example.com".to_string()],
4490 None,
4491 );
4492 let raw = BackendSearch {
4493 backend: BackendId::DuckDuckGo,
4494 source: "duckduckgo".to_string(),
4495 backend_detail: None,
4496 results: vec![
4497 SearchResult::new(
4498 1,
4499 "Drop this".to_string(),
4500 "https://other.example.com/page".to_string(),
4501 None,
4502 None,
4503 ),
4504 SearchResult::new(
4505 2,
4506 "Keep this".to_string(),
4507 "https://keep.example.com/page".to_string(),
4508 None,
4509 None,
4510 ),
4511 SearchResult::new(
4512 3,
4513 "Also drop".to_string(),
4514 "https://unrelated.example.com/page".to_string(),
4515 None,
4516 None,
4517 ),
4518 ],
4519 degraded: Vec::new(),
4520 note: None,
4521 };
4522
4523 let response =
4524 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
4525
4526 assert_eq!(response.count, 1, "only the matching domain must survive");
4527 assert_eq!(
4528 response.results[0].rank, 1,
4529 "survivor must be re-ranked to 1"
4530 );
4531 assert_eq!(response.results[0].title, "Keep this");
4532 assert!(
4533 response.receipt.degraded.iter().any(|reason| matches!(
4534 reason,
4535 DegradedReason::PostFiltered {
4536 knob: QueryKnob::Domains
4537 }
4538 )),
4539 "post-filtered degraded reason must be present"
4540 );
4541 }
4542
4543 #[test]
4544 fn fallback_receipt_carries_full_backend_chain_history() {
4545 // Verifies that the machine-readable degraded vec records every hop in
4546 // the fallback chain so callers can audit exactly what happened.
4547 let receipt = crate::tools::web::contract::SearchReceipt {
4548 backend: BackendId::Bing,
4549 backend_detail: None,
4550 requested: SearchQuery::new("fallback chain".to_string(), 5, None, Vec::new(), None),
4551 capabilities: QueryCapabilities::count_only(),
4552 honored: crate::tools::web::contract::HonoredQueryCapabilities {
4553 max_results: true,
4554 ..Default::default()
4555 },
4556 degraded: vec![
4557 DegradedReason::ChallengeDetected {
4558 backend: BackendId::DuckDuckGo,
4559 },
4560 DegradedReason::ScrapeFallback {
4561 from: BackendId::DuckDuckGo,
4562 to: BackendId::Bing,
4563 },
4564 ],
4565 latency_ms: 42,
4566 cache_hit: false,
4567 };
4568
4569 let value = serde_json::to_value(&receipt).expect("receipt must serialize");
4570 assert_eq!(value["backend"], "bing");
4571 assert_eq!(value["degraded"].as_array().unwrap().len(), 2);
4572 assert_eq!(value["degraded"][0]["kind"], "challenge_detected");
4573 assert_eq!(value["degraded"][0]["backend"], "duckduckgo");
4574 assert_eq!(value["degraded"][1]["kind"], "scrape_fallback");
4575 assert_eq!(value["degraded"][1]["from"], "duckduckgo");
4576 assert_eq!(value["degraded"][1]["to"], "bing");
4577
4578 let warning = receipt
4579 .warning()
4580 .expect("degraded receipt must produce a warning");
4581 assert!(warning.contains("bot challenge"), "{warning}");
4582 assert!(warning.contains("used bing fallback"), "{warning}");
4583 }
4584 }
4585
4585 lines RUST