返回 CodeWhale
backend.rs
根目录 / crates / tui / src / tools / web / backend.rs
1 //! Search backend selection and the shared async adapter contract.
2
3 use std::time::{Duration, Instant};
4
5 use async_trait::async_trait;
6
7 use super::adapter::{self, AdapterFailure, AdapterResult};
8 use super::contract::{BackendId, BackendSearch, DegradedReason, QueryCapabilities, SearchQuery};
9 use super::contract::{CapabilityState as QueryCapabilityState, SearchResult};
10 use crate::client::ProviderNativeSearchRequest;
11 use crate::config::SearchProvider;
12 use crate::tools::spec::{ToolContext, ToolError};
13
14 const SEARCH_BACKEND_CONFIGURATION_HINT: &str = concat!(
15 "Check network access, or configure `[search] provider` and `[search] api_key` in ",
16 "config.toml. Keyed providers include tavily, bocha, metaso, baidu, volcengine, serply, ",
17 "and sofya; metaso also accepts METASO_API_KEY, tavily accepts TAVILY_API_KEY, baidu ",
18 "accepts BAIDU_SEARCH_API_KEY, volcengine accepts VOLCENGINE_API_KEY / ",
19 "VOLCENGINE_ARK_API_KEY / ARK_API_KEY, serply accepts SERPLY_API_KEY, and sofya ",
20 "accepts SOFYA_API_KEY. SearXNG needs a trusted self-hosted `[search] base_url`. For a ",
21 "keyless route, use the default `[search] provider = \"firecrawl\"` or ",
22 "`[search] provider = \"bing\"`."
23 );
24
25 #[async_trait]
26 pub(crate) trait SearchBackend: Send + Sync {
27 fn id(&self) -> BackendId;
28 fn capabilities(&self) -> QueryCapabilities;
29 async fn search(&self, query: &SearchQuery, deadline: Instant) -> AdapterResult<BackendSearch>;
30 }
31
32 #[derive(Clone, Copy)]
33 pub(crate) struct BackendContext<'a> {
34 tool_context: &'a ToolContext,
35 }
36
37 pub(crate) enum ConfiguredSearchBackend<'a> {
38 Bing(BackendContext<'a>),
39 DuckDuckGo(BackendContext<'a>),
40 Firecrawl(BackendContext<'a>),
41 Tavily(BackendContext<'a>),
42 Bocha(BackendContext<'a>),
43 Metaso(BackendContext<'a>),
44 Searxng(BackendContext<'a>),
45 Baidu(BackendContext<'a>),
46 Volcengine(BackendContext<'a>),
47 Sofya(BackendContext<'a>),
48 Serply(BackendContext<'a>),
49 }
50
51 #[derive(Clone, Copy)]
52 struct ProviderNativeSearchBackend<'a> {
53 context: &'a ToolContext,
54 }
55
56 impl<'a> ConfiguredSearchBackend<'a> {
57 #[must_use]
58 pub(crate) fn from_provider(context: &'a ToolContext, provider: SearchProvider) -> Self {
59 let backend = BackendContext {
60 tool_context: context,
61 };
62 match provider {
63 SearchProvider::Bing => Self::Bing(backend),
64 SearchProvider::DuckDuckGo => Self::DuckDuckGo(backend),
65 SearchProvider::Firecrawl => Self::Firecrawl(backend),
66 SearchProvider::Tavily => Self::Tavily(backend),
67 SearchProvider::Bocha => Self::Bocha(backend),
68 SearchProvider::Metaso => Self::Metaso(backend),
69 SearchProvider::Searxng => Self::Searxng(backend),
70 SearchProvider::Baidu => Self::Baidu(backend),
71 SearchProvider::Volcengine => Self::Volcengine(backend),
72 SearchProvider::Sofya => Self::Sofya(backend),
73 SearchProvider::Serply => Self::Serply(backend),
74 }
75 }
76
77 const fn provider(&self) -> SearchProvider {
78 match self {
79 Self::Bing(_) => SearchProvider::Bing,
80 Self::DuckDuckGo(_) => SearchProvider::DuckDuckGo,
81 Self::Firecrawl(_) => SearchProvider::Firecrawl,
82 Self::Tavily(_) => SearchProvider::Tavily,
83 Self::Bocha(_) => SearchProvider::Bocha,
84 Self::Metaso(_) => SearchProvider::Metaso,
85 Self::Searxng(_) => SearchProvider::Searxng,
86 Self::Baidu(_) => SearchProvider::Baidu,
87 Self::Volcengine(_) => SearchProvider::Volcengine,
88 Self::Sofya(_) => SearchProvider::Sofya,
89 Self::Serply(_) => SearchProvider::Serply,
90 }
91 }
92
93 const fn context(&self) -> &BackendContext<'a> {
94 match self {
95 Self::Bing(context)
96 | Self::DuckDuckGo(context)
97 | Self::Firecrawl(context)
98 | Self::Tavily(context)
99 | Self::Bocha(context)
100 | Self::Metaso(context)
101 | Self::Searxng(context)
102 | Self::Baidu(context)
103 | Self::Volcengine(context)
104 | Self::Sofya(context)
105 | Self::Serply(context) => context,
106 }
107 }
108 }
109
110 pub(crate) struct SearchBackendChain<'a> {
111 backends: Vec<Box<dyn SearchBackend + 'a>>,
112 }
113
114 #[derive(Debug)]
115 pub(crate) struct ChainedSearch {
116 pub(crate) raw: BackendSearch,
117 pub(crate) capabilities: QueryCapabilities,
118 }
119
120 impl<'a> SearchBackendChain<'a> {
121 #[must_use]
122 pub(crate) fn from_context(context: &'a ToolContext) -> Self {
123 let selected = context.search_provider;
124 let mut backends: Vec<Box<dyn SearchBackend + 'a>> = Vec::new();
125 if should_prepend_provider_native(context) {
126 backends.push(Box::new(ProviderNativeSearchBackend { context }));
127 }
128 backends.push(Box::new(ConfiguredSearchBackend::from_provider(
129 context, selected,
130 )));
131 if !matches!(selected, SearchProvider::Bing | SearchProvider::DuckDuckGo) {
132 backends.push(Box::new(ConfiguredSearchBackend::from_provider(
133 context,
134 SearchProvider::DuckDuckGo,
135 )));
136 }
137 Self { backends }
138 }
139
140 #[must_use]
141 pub(crate) fn initial_backend(&self) -> BackendId {
142 self.backends
143 .first()
144 .expect("a search chain always has a configured backend")
145 .id()
146 }
147
148 pub(crate) async fn search(
149 &self,
150 query: &SearchQuery,
151 deadline: Instant,
152 first_attempt_budget: Option<Duration>,
153 fallback_budget_after_first: Option<Duration>,
154 ) -> AdapterResult<ChainedSearch> {
155 let backends = self
156 .backends
157 .iter()
158 .map(|backend| backend.as_ref())
159 .collect::<Vec<_>>();
160 run_backend_chain(
161 &backends,
162 query,
163 deadline,
164 first_attempt_budget,
165 fallback_budget_after_first,
166 )
167 .await
168 }
169 }
170
171 fn should_prepend_provider_native(context: &ToolContext) -> bool {
172 provider_native_is_available(
173 context
174 .route_capabilities
175 .server_side_web_search
176 .is_supported(),
177 context.provider_native_search.is_some(),
178 )
179 }
180
181 const fn provider_native_is_available(capability_supported: bool, client_present: bool) -> bool {
182 capability_supported && client_present
183 }
184
185 async fn run_backend_chain(
186 backends: &[&dyn SearchBackend],
187 query: &SearchQuery,
188 mut deadline: Instant,
189 first_attempt_budget: Option<Duration>,
190 fallback_budget_after_first: Option<Duration>,
191 ) -> AdapterResult<ChainedSearch> {
192 let mut degraded = Vec::new();
193 let mut last_empty = None;
194 let mut attempted = Vec::new();
195
196 for (index, backend) in backends.iter().enumerate() {
197 if index == 1
198 && let Some(fallback_budget) = fallback_budget_after_first
199 {
200 deadline = Instant::now() + fallback_budget.max(Duration::from_millis(1));
201 }
202 let remaining = deadline.saturating_duration_since(Instant::now());
203 if remaining.is_zero() {
204 break;
205 }
206 let backend_id = backend.id();
207 if let Some(previous) = attempted.last() {
208 degraded.push(DegradedReason::BackendFallback {
209 from: *previous,
210 to: backend_id,
211 });
212 }
213 attempted.push(backend_id);
214
215 let attempts_left = u32::try_from(backends.len() - index).unwrap_or(u32::MAX);
216 let fair_share = remaining / attempts_left;
217 let attempt_budget = if index == 0 {
218 first_attempt_budget
219 .map(|budget| budget.min(remaining))
220 .unwrap_or(fair_share)
221 } else {
222 fair_share
223 }
224 .max(Duration::from_millis(1));
225 let attempt_deadline = Instant::now() + attempt_budget;
226
227 let pending_host = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
228 let result = tokio::time::timeout(
229 attempt_budget,
230 adapter::HOST_PENDING.scope(
231 std::sync::Arc::clone(&pending_host),
232 backend.search(query, attempt_deadline),
233 ),
234 )
235 .await
236 .map_err(|_| {
237 let error = ToolError::Timeout {
238 seconds: u64::try_from(attempt_budget.as_millis())
239 .unwrap_or(u64::MAX)
240 .div_ceil(1_000),
241 };
242 if pending_host.load(std::sync::atomic::Ordering::SeqCst) {
243 AdapterFailure::host(error)
244 } else {
245 error.into()
246 }
247 })
248 .and_then(std::convert::identity);
249
250 match result {
251 Ok(mut raw) => {
252 let capabilities = backend.capabilities();
253 crate::tools::web_search::apply_domain_constraints(query, capabilities, &mut raw);
254 if !raw.results.is_empty() {
255 degraded.append(&mut raw.degraded);
256 raw.degraded = degraded;
257 return Ok(ChainedSearch { raw, capabilities });
258 }
259 degraded.push(DegradedReason::NoUsableResults {
260 backend: backend_id,
261 });
262 degraded.append(&mut raw.degraded);
263 last_empty = Some((raw, capabilities));
264 }
265 Err(error) if !error.content() || is_fail_closed(&error.error) => return Err(error),
266 Err(error) if backends.len() == 1 => return Err(error),
267 Err(_) => degraded.push(DegradedReason::BackendUnavailable {
268 backend: backend_id,
269 }),
270 }
271 }
272
273 if let Some((mut raw, capabilities)) = last_empty {
274 raw.degraded = degraded;
275 return Ok(ChainedSearch { raw, capabilities });
276 }
277
278 if attempted.is_empty() {
279 return Err((ToolError::Timeout { seconds: 1 }).into());
280 }
281
282 let backend_ids = attempted
283 .into_iter()
284 .map(BackendId::as_str)
285 .collect::<Vec<_>>()
286 .join(", ");
287 Err((ToolError::not_available(format!(
288 "web search backends unavailable: {backend_ids}. {SEARCH_BACKEND_CONFIGURATION_HINT}"
289 )))
290 .into())
291 }
292
293 const fn is_fail_closed(error: &ToolError) -> bool {
294 matches!(
295 error,
296 ToolError::InvalidInput { .. }
297 | ToolError::MissingField { .. }
298 | ToolError::PathEscape { .. }
299 | ToolError::Cancelled { .. }
300 | ToolError::Timeout { seconds: 0 }
301 | ToolError::PermissionDenied { .. }
302 )
303 }
304
305 #[async_trait]
306 impl SearchBackend for ConfiguredSearchBackend<'_> {
307 fn id(&self) -> BackendId {
308 match self.provider() {
309 SearchProvider::Bing => BackendId::Bing,
310 SearchProvider::DuckDuckGo => BackendId::DuckDuckGo,
311 SearchProvider::Firecrawl => BackendId::Firecrawl,
312 SearchProvider::Tavily => BackendId::Tavily,
313 SearchProvider::Bocha => BackendId::Bocha,
314 SearchProvider::Metaso => BackendId::Metaso,
315 SearchProvider::Searxng => BackendId::Searxng,
316 SearchProvider::Baidu => BackendId::Baidu,
317 SearchProvider::Volcengine => BackendId::Volcengine,
318 SearchProvider::Sofya => BackendId::Sofya,
319 SearchProvider::Serply => BackendId::Serply,
320 }
321 }
322
323 fn capabilities(&self) -> QueryCapabilities {
324 // All current adapters enforce result count. Recency and locale are
325 // forwarded where the backend's API takes them (see `QueryFilters` in
326 // `web_search.rs`); every other knob is post-filtered by the shared
327 // harness or reported as not honored.
328 let (recency, locale) = match self.provider() {
329 SearchProvider::Firecrawl | SearchProvider::Searxng => (true, true),
330 SearchProvider::Tavily => (true, false),
331 SearchProvider::Serply => (false, true),
332 _ => (false, false),
333 };
334 let state = |supported: bool| {
335 if supported {
336 QueryCapabilityState::Supported
337 } else {
338 QueryCapabilityState::Unsupported
339 }
340 };
341 QueryCapabilities {
342 recency: state(recency),
343 locale: state(locale),
344 ..QueryCapabilities::count_only()
345 }
346 }
347
348 async fn search(&self, query: &SearchQuery, deadline: Instant) -> AdapterResult<BackendSearch> {
349 crate::tools::web_search::run_backend_search(
350 self.provider(),
351 query,
352 deadline,
353 self.context().tool_context,
354 )
355 .await
356 }
357 }
358
359 #[async_trait]
360 impl SearchBackend for ProviderNativeSearchBackend<'_> {
361 fn id(&self) -> BackendId {
362 BackendId::ProviderNative
363 }
364
365 fn capabilities(&self) -> QueryCapabilities {
366 QueryCapabilities {
367 max_results: QueryCapabilityState::Supported,
368 recency: QueryCapabilityState::Unsupported,
369 domains: QueryCapabilityState::Supported,
370 locale: QueryCapabilityState::Unsupported,
371 published_date: QueryCapabilityState::Unknown,
372 }
373 }
374
375 async fn search(
376 &self,
377 query: &SearchQuery,
378 _deadline: Instant,
379 ) -> AdapterResult<BackendSearch> {
380 if !self
381 .context
382 .route_capabilities
383 .server_side_web_search
384 .is_supported()
385 {
386 return Err((ToolError::not_available(
387 "active route does not report provider-native web search",
388 ))
389 .into());
390 }
391 let client = self
392 .context
393 .provider_native_search
394 .as_ref()
395 .ok_or_else(|| ToolError::not_available("provider-native search client unavailable"))?;
396 // Moonshot/Kimi, Z.AI, MiMo, and the Responses-dialect routes cannot
397 // express domain filters in their native wire contracts. Declining
398 // here must not fail the whole search: report this backend unavailable
399 // so the chain falls back to the configured provider or DuckDuckGo,
400 // which honor domains natively or through post-filtering.
401 let domain_limit = client.maximum_domain_count();
402 if !query.domains.is_empty() && domain_limit == Some(0) {
403 return Err((ToolError::not_available(format!(
404 "{} native web search cannot honor domain filters",
405 client.provider().as_str()
406 )))
407 .into());
408 }
409 if let Some(maximum) = domain_limit
410 && query.domains.len() > maximum
411 {
412 return Err((ToolError::invalid_input(format!(
413 "{} native web search accepts at most {maximum} domains",
414 client.provider().as_str()
415 )))
416 .into());
417 }
418 let host = client.host().ok_or_else(|| {
419 ToolError::execution_failed("provider-native search endpoint has no valid host")
420 })?;
421 crate::tools::web_search::check_policy(
422 self.context.network_policy.as_ref(),
423 host.as_str(),
424 )?;
425 let response = client
426 .search(&ProviderNativeSearchRequest {
427 query: query.query.clone(),
428 max_results: query.max_results,
429 domains: query.domains.clone(),
430 })
431 .await
432 .map_err(|error| {
433 ToolError::execution_failed(format!(
434 "{} provider-native web search failed: {error}",
435 client.provider().as_str()
436 ))
437 })?;
438 let entries = response
439 .citations
440 .into_iter()
441 .map(|citation| super::contract::CapturedSearchEntry {
442 title: citation.title,
443 url: citation.url,
444 snippet: citation.snippet,
445 published: citation.published,
446 })
447 .collect();
448 let results = crate::tools::web_search::normalize_captured_entries(
449 entries,
450 self.context,
451 _deadline.saturating_duration_since(Instant::now()),
452 )
453 .await?
454 .into_iter()
455 .enumerate()
456 .map(|(index, entry)| {
457 SearchResult::new(
458 index + 1,
459 entry.title,
460 entry.url,
461 entry.snippet,
462 entry.published,
463 )
464 })
465 .collect();
466 Ok(BackendSearch {
467 backend: BackendId::ProviderNative,
468 source: format!(
469 "provider-native/{}/{}",
470 client.provider().as_str(),
471 client.model()
472 ),
473 backend_detail: Some(host),
474 results,
475 degraded: if response.truncated {
476 vec![DegradedReason::AnswerCutByProvider]
477 } else {
478 Vec::new()
479 },
480 note: response.answer,
481 })
482 }
483 }
484
485 #[cfg(test)]
486 mod tests {
487 use std::sync::{Arc, Mutex};
488
489 use super::*;
490
491 struct FakeBackend {
492 id: BackendId,
493 result: Result<Vec<super::super::contract::SearchResult>, ToolError>,
494 }
495
496 struct DeadlineBackend {
497 id: BackendId,
498 observed_budget: Arc<Mutex<Option<Duration>>>,
499 delay: Duration,
500 }
501
502 #[async_trait]
503 impl SearchBackend for FakeBackend {
504 fn id(&self) -> BackendId {
505 self.id
506 }
507
508 fn capabilities(&self) -> QueryCapabilities {
509 QueryCapabilities::count_only()
510 }
511
512 async fn search(
513 &self,
514 _query: &SearchQuery,
515 _deadline: Instant,
516 ) -> AdapterResult<BackendSearch> {
517 Ok(BackendSearch {
518 backend: self.id,
519 source: self.id.as_str().to_string(),
520 backend_detail: None,
521 results: self.result.clone()?,
522 degraded: Vec::new(),
523 note: None,
524 })
525 }
526 }
527
528 #[async_trait]
529 impl SearchBackend for DeadlineBackend {
530 fn id(&self) -> BackendId {
531 self.id
532 }
533
534 fn capabilities(&self) -> QueryCapabilities {
535 QueryCapabilities::count_only()
536 }
537
538 async fn search(
539 &self,
540 _query: &SearchQuery,
541 deadline: Instant,
542 ) -> AdapterResult<BackendSearch> {
543 *self.observed_budget.lock().expect("budget lock") =
544 Some(deadline.saturating_duration_since(Instant::now()));
545 tokio::time::sleep(self.delay).await;
546 Ok(BackendSearch {
547 backend: self.id,
548 source: self.id.as_str().to_string(),
549 backend_detail: None,
550 results: vec![result()],
551 degraded: Vec::new(),
552 note: None,
553 })
554 }
555 }
556
557 fn query() -> SearchQuery {
558 SearchQuery::new("bounded chain".to_string(), 5, None, Vec::new(), None)
559 }
560
561 fn result() -> super::super::contract::SearchResult {
562 super::super::contract::SearchResult::new(
563 1,
564 "Fallback result".to_string(),
565 "https://example.com/result".to_string(),
566 None,
567 None,
568 )
569 }
570
571 #[test]
572 fn every_configured_provider_maps_to_one_explicit_backend_adapter() {
573 let cases = [
574 (SearchProvider::Bing, BackendId::Bing),
575 (SearchProvider::DuckDuckGo, BackendId::DuckDuckGo),
576 (SearchProvider::Firecrawl, BackendId::Firecrawl),
577 (SearchProvider::Tavily, BackendId::Tavily),
578 (SearchProvider::Bocha, BackendId::Bocha),
579 (SearchProvider::Metaso, BackendId::Metaso),
580 (SearchProvider::Searxng, BackendId::Searxng),
581 (SearchProvider::Baidu, BackendId::Baidu),
582 (SearchProvider::Volcengine, BackendId::Volcengine),
583 (SearchProvider::Sofya, BackendId::Sofya),
584 (SearchProvider::Serply, BackendId::Serply),
585 ];
586
587 for (provider, expected) in cases {
588 let mut context = ToolContext::new(std::path::PathBuf::from("."));
589 context.search_provider = provider;
590 let backend = ConfiguredSearchBackend::from_provider(&context, provider);
591 assert_eq!(backend.id(), expected);
592 assert_eq!(
593 backend.capabilities().max_results,
594 super::super::contract::CapabilityState::Supported
595 );
596 }
597 }
598
599 #[test]
600 fn provider_native_is_fail_closed_without_both_fact_and_client() {
601 assert!(!provider_native_is_available(false, false));
602 assert!(!provider_native_is_available(true, false));
603 assert!(!provider_native_is_available(false, true));
604 assert!(provider_native_is_available(true, true));
605 }
606
607 #[tokio::test]
608 async fn unavailable_api_falls_back_with_explicit_receipts() {
609 let api = FakeBackend {
610 id: BackendId::Tavily,
611 result: Err(ToolError::execution_failed(
612 "provider detail must stay private",
613 )),
614 };
615 let scrape = FakeBackend {
616 id: BackendId::DuckDuckGo,
617 result: Ok(vec![result()]),
618 };
619 let response = run_backend_chain(
620 &[&api, &scrape],
621 &query(),
622 Instant::now() + Duration::from_secs(1),
623 None,
624 None,
625 )
626 .await
627 .expect("fallback should succeed");
628
629 assert_eq!(response.raw.backend, BackendId::DuckDuckGo);
630 assert_eq!(
631 response.raw.degraded,
632 vec![
633 DegradedReason::BackendUnavailable {
634 backend: BackendId::Tavily,
635 },
636 DegradedReason::BackendFallback {
637 from: BackendId::Tavily,
638 to: BackendId::DuckDuckGo,
639 },
640 ]
641 );
642 }
643
644 #[tokio::test]
645 async fn provider_native_to_api_to_scrape_records_every_transition() {
646 let native = FakeBackend {
647 id: BackendId::ProviderNative,
648 result: Err(ToolError::execution_failed("native unavailable")),
649 };
650 let api = FakeBackend {
651 id: BackendId::Tavily,
652 result: Err(ToolError::execution_failed("API unavailable")),
653 };
654 let scrape = FakeBackend {
655 id: BackendId::DuckDuckGo,
656 result: Ok(vec![result()]),
657 };
658
659 let response = run_backend_chain(
660 &[&native, &api, &scrape],
661 &query(),
662 Instant::now() + Duration::from_secs(1),
663 None,
664 None,
665 )
666 .await
667 .expect("final scrape fallback should succeed");
668
669 assert_eq!(response.raw.backend, BackendId::DuckDuckGo);
670 assert_eq!(
671 response.raw.degraded,
672 vec![
673 DegradedReason::BackendUnavailable {
674 backend: BackendId::ProviderNative,
675 },
676 DegradedReason::BackendFallback {
677 from: BackendId::ProviderNative,
678 to: BackendId::Tavily,
679 },
680 DegradedReason::BackendUnavailable {
681 backend: BackendId::Tavily,
682 },
683 DegradedReason::BackendFallback {
684 from: BackendId::Tavily,
685 to: BackendId::DuckDuckGo,
686 },
687 ]
688 );
689 }
690
691 #[tokio::test]
692 async fn zero_domain_native_providers_decline_without_failing_the_chain() {
693 use crate::config::{Config, ProviderConfig, ProvidersConfig};
694
695 let moonshot_config = Config {
696 provider: Some("moonshot".to_string()),
697 providers: Some(ProvidersConfig {
698 moonshot: ProviderConfig {
699 api_key: Some("moonshot-test-key".to_string()),
700 base_url: Some("https://api.moonshot.ai/v1".to_string()),
701 model: Some("kimi-k3".to_string()),
702 ..ProviderConfig::default()
703 },
704 ..ProvidersConfig::default()
705 }),
706 ..Config::default()
707 };
708 let tmp = tempfile::tempdir().expect("tempdir");
709 let mut context = ToolContext::new(tmp.path().to_path_buf());
710 context.route_capabilities.server_side_web_search =
711 codewhale_config::route::CapabilityState::Supported;
712 context.provider_native_search = Some(
713 crate::client::ProviderNativeSearchClient::new(
714 crate::client::CodewhaleClient::new(&moonshot_config)
715 .expect("test Moonshot client"),
716 )
717 .expect("Moonshot native adapter"),
718 );
719 let backend = ProviderNativeSearchBackend { context: &context };
720
721 let domain_query = SearchQuery::new(
722 "bounded chain".to_string(),
723 5,
724 None,
725 vec!["example.com".to_string()],
726 None,
727 );
728 let error = backend
729 .search(&domain_query, Instant::now() + Duration::from_secs(1))
730 .await
731 .expect_err("Moonshot native search must decline domain-filtered queries");
732 assert!(
733 matches!(error.error, ToolError::NotAvailable { .. }),
734 "declining must stay fallback-shaped, not fail-closed: {error:?}"
735 );
736
737 let xai_config = Config {
738 provider: Some("xai".to_string()),
739 providers: Some(ProvidersConfig {
740 xai: ProviderConfig {
741 api_key: Some("xai-test-key".to_string()),
742 base_url: Some("https://api.x.ai/v1".to_string()),
743 model: Some("grok-4.5".to_string()),
744 ..ProviderConfig::default()
745 },
746 ..ProvidersConfig::default()
747 }),
748 ..Config::default()
749 };
750 let mut xai_context = ToolContext::new(tmp.path().to_path_buf());
751 xai_context.route_capabilities.server_side_web_search =
752 codewhale_config::route::CapabilityState::Supported;
753 xai_context.provider_native_search = Some(
754 crate::client::ProviderNativeSearchClient::new(
755 crate::client::CodewhaleClient::new(&xai_config).expect("test xAI client"),
756 )
757 .expect("xAI native adapter"),
758 );
759 let oversized_domain_query = SearchQuery::new(
760 "bounded chain".to_string(),
761 5,
762 None,
763 [
764 "a.example",
765 "b.example",
766 "c.example",
767 "d.example",
768 "e.example",
769 "f.example",
770 ]
771 .iter()
772 .map(|domain| domain.to_string())
773 .collect(),
774 None,
775 );
776 let error = ProviderNativeSearchBackend {
777 context: &xai_context,
778 }
779 .search(
780 &oversized_domain_query,
781 Instant::now() + Duration::from_secs(1),
782 )
783 .await
784 .expect_err("too many domains stays a typed user error");
785 assert!(
786 matches!(error.error, ToolError::InvalidInput { .. }),
787 "over the provider limit must stay fail-closed: {error:?}"
788 );
789 }
790
791 #[tokio::test]
792 async fn first_attempt_budget_overrides_the_default_fair_share() {
793 let observed_budget = Arc::new(Mutex::new(None));
794 let volcengine = DeadlineBackend {
795 id: BackendId::Volcengine,
796 observed_budget: Arc::clone(&observed_budget),
797 delay: Duration::ZERO,
798 };
799 let fallback = FakeBackend {
800 id: BackendId::DuckDuckGo,
801 result: Ok(vec![result()]),
802 };
803 let first_attempt_budget = Duration::from_millis(1_500);
804 let response = run_backend_chain(
805 &[&volcengine, &fallback],
806 &query(),
807 Instant::now() + Duration::from_secs(2),
808 Some(first_attempt_budget),
809 None,
810 )
811 .await
812 .expect("the first backend should complete inside its dedicated budget");
813
814 assert_eq!(response.raw.backend, BackendId::Volcengine);
815 let observed = observed_budget
816 .lock()
817 .expect("budget lock")
818 .expect("first backend must observe a deadline");
819 assert!(
820 observed > Duration::from_millis(1_250),
821 "dedicated first-attempt budget should exceed the default one-second fair share: {observed:?}"
822 );
823 assert!(observed <= first_attempt_budget);
824 }
825
826 #[tokio::test]
827 async fn provider_native_unused_budget_does_not_extend_fallback_deadline() {
828 let native = FakeBackend {
829 id: BackendId::ProviderNative,
830 result: Err(ToolError::execution_failed("native unavailable")),
831 };
832 let observed_budget = Arc::new(Mutex::new(None));
833 let fallback = DeadlineBackend {
834 id: BackendId::DuckDuckGo,
835 observed_budget: Arc::clone(&observed_budget),
836 delay: Duration::from_millis(200),
837 };
838 let fallback_budget = Duration::from_millis(30);
839 let error = run_backend_chain(
840 &[&native, &fallback],
841 &query(),
842 Instant::now() + Duration::from_millis(500),
843 Some(Duration::from_millis(500)),
844 Some(fallback_budget),
845 )
846 .await
847 .expect_err("blocking fallback must stop at its own budget");
848
849 assert!(matches!(error.error, ToolError::NotAvailable { .. }));
850 let observed = observed_budget
851 .lock()
852 .expect("budget lock")
853 .expect("fallback must observe a deadline");
854 assert!(observed <= fallback_budget);
855 }
856
857 #[tokio::test]
858 async fn all_unavailable_returns_actionable_error_without_private_details() {
859 let private_error = "secret provider response";
860 let api = FakeBackend {
861 id: BackendId::Bocha,
862 result: Err(ToolError::execution_failed(private_error)),
863 };
864 let scrape = FakeBackend {
865 id: BackendId::DuckDuckGo,
866 result: Err(ToolError::execution_failed("different private response")),
867 };
868 let error = run_backend_chain(
869 &[&api, &scrape],
870 &query(),
871 Instant::now() + Duration::from_secs(1),
872 None,
873 None,
874 )
875 .await
876 .expect_err("all-down chain must fail");
877 let message = error.to_string();
878
879 assert!(matches!(error.error, ToolError::NotAvailable { .. }));
880 assert!(message.contains("bocha, duckduckgo"));
881 for provider in [
882 "tavily",
883 "bocha",
884 "metaso",
885 "baidu",
886 "volcengine",
887 "serply",
888 "sofya",
889 ] {
890 assert!(
891 message.contains(provider),
892 "configuration hint must name {provider}: `{message}`"
893 );
894 }
895 assert!(message.contains("[search] provider"));
896 assert!(message.contains("[search] api_key"));
897 assert!(message.contains("config.toml"));
898 assert!(message.contains("METASO_API_KEY"));
899 assert!(message.contains("TAVILY_API_KEY"));
900 assert!(message.contains("BAIDU_SEARCH_API_KEY"));
901 assert!(message.contains("VOLCENGINE_API_KEY"));
902 assert!(message.contains("VOLCENGINE_ARK_API_KEY"));
903 assert!(message.contains("ARK_API_KEY"));
904 assert!(message.contains("SERPLY_API_KEY"));
905 assert!(message.contains("SOFYA_API_KEY"));
906 assert!(message.contains("[search] base_url"));
907 assert!(message.contains("provider = \"firecrawl\""));
908 assert!(message.contains("provider = \"bing\""));
909 assert!(!message.contains(private_error));
910 assert!(!message.contains("different private response"));
911 }
912
913 #[tokio::test]
914 async fn policy_failure_does_not_leak_query_to_fallback() {
915 struct CountingBackend {
916 calls: Arc<std::sync::atomic::AtomicUsize>,
917 }
918 #[async_trait]
919 impl SearchBackend for CountingBackend {
920 fn id(&self) -> BackendId {
921 BackendId::DuckDuckGo
922 }
923
924 fn capabilities(&self) -> QueryCapabilities {
925 QueryCapabilities::count_only()
926 }
927
928 async fn search(
929 &self,
930 _query: &SearchQuery,
931 _deadline: Instant,
932 ) -> AdapterResult<BackendSearch> {
933 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
934 Err(ToolError::execution_failed("unexpected fallback").into())
935 }
936 }
937
938 let api = FakeBackend {
939 id: BackendId::Searxng,
940 result: Err(ToolError::permission_denied("policy blocked")),
941 };
942 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
943 let scrape = CountingBackend {
944 calls: Arc::clone(&calls),
945 };
946 let error = run_backend_chain(
947 &[&api, &scrape],
948 &query(),
949 Instant::now() + Duration::from_secs(1),
950 None,
951 None,
952 )
953 .await
954 .expect_err("policy error must fail closed");
955
956 assert!(matches!(error.error, ToolError::PermissionDenied { .. }));
957 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
958 }
959
960 #[tokio::test]
961 async fn empty_api_falls_back_and_records_no_usable_results() {
962 let api = FakeBackend {
963 id: BackendId::Metaso,
964 result: Ok(Vec::new()),
965 };
966 let scrape = FakeBackend {
967 id: BackendId::DuckDuckGo,
968 result: Ok(vec![result()]),
969 };
970 let response = run_backend_chain(
971 &[&api, &scrape],
972 &query(),
973 Instant::now() + Duration::from_secs(1),
974 None,
975 None,
976 )
977 .await
978 .expect("empty API response should fall back");
979
980 assert_eq!(
981 response.raw.degraded,
982 vec![
983 DegradedReason::NoUsableResults {
984 backend: BackendId::Metaso,
985 },
986 DegradedReason::BackendFallback {
987 from: BackendId::Metaso,
988 to: BackendId::DuckDuckGo,
989 },
990 ]
991 );
992 }
993
994 #[tokio::test]
995 async fn domain_filtered_results_fall_back_before_chain_success() {
996 let native = FakeBackend {
997 id: BackendId::ProviderNative,
998 result: Ok(vec![SearchResult::new(
999 1,
1000 "Outside source".to_string(),
1001 "https://outside.test/result".to_string(),
1002 None,
1003 None,
1004 )]),
1005 };
1006 let configured = FakeBackend {
1007 id: BackendId::Searxng,
1008 result: Ok(vec![SearchResult::new(
1009 1,
1010 "Matching source".to_string(),
1011 "https://docs.rs/example/latest/example/".to_string(),
1012 None,
1013 None,
1014 )]),
1015 };
1016 let constrained = SearchQuery::new(
1017 "example docs".to_string(),
1018 5,
1019 None,
1020 vec!["docs.rs".to_string()],
1021 None,
1022 );
1023
1024 let response = run_backend_chain(
1025 &[&native, &configured],
1026 &constrained,
1027 Instant::now() + Duration::from_secs(1),
1028 None,
1029 None,
1030 )
1031 .await
1032 .expect("configured backend should satisfy the domain constraint");
1033
1034 assert_eq!(response.raw.backend, BackendId::Searxng);
1035 assert_eq!(response.raw.results.len(), 1);
1036 assert!(response.raw.degraded.iter().any(|reason| matches!(
1037 reason,
1038 DegradedReason::NoUsableResults {
1039 backend: BackendId::ProviderNative
1040 }
1041 )));
1042 assert!(response.raw.degraded.iter().any(|reason| matches!(
1043 reason,
1044 DegradedReason::BackendFallback {
1045 from: BackendId::ProviderNative,
1046 to: BackendId::Searxng
1047 }
1048 )));
1049 }
1050 #[tokio::test]
1051 async fn adapter_origins_and_selected_host_timeout_stop_backend_fallback() {
1052 use super::super::adapter::FailureOrigin;
1053 struct Refusal {
1054 origin: FailureOrigin,
1055 delay: bool,
1056 }
1057 #[async_trait]
1058 impl SearchBackend for Refusal {
1059 fn id(&self) -> BackendId {
1060 BackendId::Tavily
1061 }
1062 fn capabilities(&self) -> QueryCapabilities {
1063 QueryCapabilities::count_only()
1064 }
1065 async fn search(
1066 &self,
1067 _query: &SearchQuery,
1068 _deadline: Instant,
1069 ) -> AdapterResult<BackendSearch> {
1070 if self.delay {
1071 if self.origin == FailureOrigin::Host {
1072 let _ = adapter::HOST_PENDING.try_with(|pending| {
1073 pending.store(true, std::sync::atomic::Ordering::SeqCst)
1074 });
1075 }
1076 tokio::time::sleep(Duration::from_secs(10)).await;
1077 }
1078 Err(AdapterFailure {
1079 origin: self.origin,
1080 error: ToolError::execution_failed(
1081 "No readable page content was found at fixture",
1082 ),
1083 })
1084 }
1085 }
1086 struct Count(std::sync::atomic::AtomicUsize);
1087 #[async_trait]
1088 impl SearchBackend for Count {
1089 fn id(&self) -> BackendId {
1090 BackendId::Bing
1091 }
1092 fn capabilities(&self) -> QueryCapabilities {
1093 QueryCapabilities::count_only()
1094 }
1095 async fn search(
1096 &self,
1097 _query: &SearchQuery,
1098 _deadline: Instant,
1099 ) -> AdapterResult<BackendSearch> {
1100 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1101 Ok(BackendSearch {
1102 backend: BackendId::Bing,
1103 source: "bing".into(),
1104 backend_detail: None,
1105 results: vec![result()],
1106 degraded: vec![],
1107 note: None,
1108 })
1109 }
1110 }
1111 let query = SearchQuery::new("authorized".into(), 5, None, vec![], None);
1112 for (origin, delay) in [
1113 (FailureOrigin::Host, false),
1114 (FailureOrigin::CaptureGuard, false),
1115 (FailureOrigin::Host, true),
1116 (FailureOrigin::ContentOrProvider, false),
1117 (FailureOrigin::ContentOrProvider, true),
1118 ] {
1119 let first = Refusal { origin, delay };
1120 let second = Count(std::sync::atomic::AtomicUsize::new(0));
1121 let response = run_backend_chain(
1122 &[&first, &second],
1123 &query,
1124 Instant::now() + Duration::from_millis(100),
1125 None,
1126 None,
1127 )
1128 .await;
1129 assert_eq!(response.is_ok(), origin == FailureOrigin::ContentOrProvider);
1130 assert_eq!(
1131 second.0.load(std::sync::atomic::Ordering::SeqCst),
1132 usize::from(origin == FailureOrigin::ContentOrProvider)
1133 );
1134 }
1135 }
1136 }
1137
1137 lines RUST