| 1 | //! Provider-neutral web-search request, result, and receipt types. |
| 2 | //! |
| 3 | //! Search backends vary widely in what they accept and return. These types are |
| 4 | //! the model-visible honesty boundary shared by `web_search` and `web.run`: |
| 5 | //! callers can distinguish requested knobs, actually honored behavior, and |
| 6 | //! degraded/post-filtered execution without depending on provider payloads. |
| 7 | |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | |
| 10 | pub(crate) const MAX_SEARCH_RESULTS: u8 = 10; |
| 11 | |
| 12 | /// Shared retrieval defaults. `web_search` and `web.run` both derive their |
| 13 | /// search knobs from these so the surfaces cannot drift apart. |
| 14 | pub(crate) const DEFAULT_SEARCH_RESULTS: usize = 5; |
| 15 | pub(crate) const DEFAULT_SEARCH_TIMEOUT_MS: u64 = 15_000; |
| 16 | pub(crate) const MAX_SEARCH_TIMEOUT_MS: u64 = 60_000; |
| 17 | |
| 18 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 19 | #[serde(rename_all = "snake_case")] |
| 20 | pub(crate) enum BackendId { |
| 21 | ProviderNative, |
| 22 | Bing, |
| 23 | #[serde(rename = "duckduckgo")] |
| 24 | DuckDuckGo, |
| 25 | Tavily, |
| 26 | Bocha, |
| 27 | Metaso, |
| 28 | Searxng, |
| 29 | Baidu, |
| 30 | Volcengine, |
| 31 | Sofya, |
| 32 | } |
| 33 | |
| 34 | impl BackendId { |
| 35 | #[must_use] |
| 36 | pub(crate) const fn as_str(self) -> &'static str { |
| 37 | match self { |
| 38 | Self::ProviderNative => "provider_native", |
| 39 | Self::Bing => "bing", |
| 40 | Self::DuckDuckGo => "duckduckgo", |
| 41 | Self::Tavily => "tavily", |
| 42 | Self::Bocha => "bocha", |
| 43 | Self::Metaso => "metaso", |
| 44 | Self::Searxng => "searxng", |
| 45 | Self::Baidu => "baidu", |
| 46 | Self::Volcengine => "volcengine", |
| 47 | Self::Sofya => "sofya", |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 53 | #[serde(rename_all = "snake_case")] |
| 54 | pub(crate) enum Recency { |
| 55 | Day, |
| 56 | Week, |
| 57 | Month, |
| 58 | Year, |
| 59 | Days(u16), |
| 60 | } |
| 61 | |
| 62 | impl Recency { |
| 63 | #[must_use] |
| 64 | #[cfg(test)] |
| 65 | pub(crate) const fn days(self) -> u16 { |
| 66 | match self { |
| 67 | Self::Day => 1, |
| 68 | Self::Week => 7, |
| 69 | Self::Month => 30, |
| 70 | Self::Year => 365, |
| 71 | Self::Days(days) => days, |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 77 | pub(crate) struct SearchQuery { |
| 78 | pub(crate) query: String, |
| 79 | pub(crate) max_results: u8, |
| 80 | #[serde(skip_serializing_if = "Option::is_none")] |
| 81 | pub(crate) recency: Option<Recency>, |
| 82 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 83 | pub(crate) domains: Vec<String>, |
| 84 | #[serde(skip_serializing_if = "Option::is_none")] |
| 85 | pub(crate) locale: Option<String>, |
| 86 | } |
| 87 | |
| 88 | impl SearchQuery { |
| 89 | #[must_use] |
| 90 | pub(crate) fn new( |
| 91 | query: String, |
| 92 | max_results: usize, |
| 93 | recency: Option<Recency>, |
| 94 | domains: Vec<String>, |
| 95 | locale: Option<String>, |
| 96 | ) -> Self { |
| 97 | let mut domains = domains |
| 98 | .into_iter() |
| 99 | .map(|domain| { |
| 100 | let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase(); |
| 101 | domain.trim_start_matches("www.").to_string() |
| 102 | }) |
| 103 | .filter(|domain| !domain.is_empty()) |
| 104 | .collect::<Vec<_>>(); |
| 105 | domains.sort_unstable(); |
| 106 | domains.dedup(); |
| 107 | Self { |
| 108 | query, |
| 109 | max_results: u8::try_from(max_results.clamp(1, usize::from(MAX_SEARCH_RESULTS))) |
| 110 | .unwrap_or(MAX_SEARCH_RESULTS), |
| 111 | recency, |
| 112 | domains, |
| 113 | locale: locale |
| 114 | .map(|value| value.trim().to_string()) |
| 115 | .filter(|value| !value.is_empty()), |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 121 | #[serde(rename_all = "snake_case")] |
| 122 | pub(crate) enum CapabilityState { |
| 123 | Supported, |
| 124 | Unsupported, |
| 125 | Unknown, |
| 126 | } |
| 127 | |
| 128 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 129 | pub(crate) struct QueryCapabilities { |
| 130 | pub(crate) max_results: CapabilityState, |
| 131 | pub(crate) recency: CapabilityState, |
| 132 | pub(crate) domains: CapabilityState, |
| 133 | pub(crate) locale: CapabilityState, |
| 134 | pub(crate) published_date: CapabilityState, |
| 135 | } |
| 136 | |
| 137 | impl QueryCapabilities { |
| 138 | #[must_use] |
| 139 | pub(crate) const fn count_only() -> Self { |
| 140 | Self { |
| 141 | max_results: CapabilityState::Supported, |
| 142 | recency: CapabilityState::Unsupported, |
| 143 | domains: CapabilityState::Unsupported, |
| 144 | locale: CapabilityState::Unsupported, |
| 145 | published_date: CapabilityState::Unknown, |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 151 | pub(crate) struct HonoredQueryCapabilities { |
| 152 | pub(crate) max_results: bool, |
| 153 | pub(crate) recency: bool, |
| 154 | pub(crate) domains: bool, |
| 155 | pub(crate) locale: bool, |
| 156 | } |
| 157 | |
| 158 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 159 | #[serde(rename_all = "snake_case")] |
| 160 | pub(crate) enum QueryKnob { |
| 161 | Recency, |
| 162 | Domains, |
| 163 | Locale, |
| 164 | } |
| 165 | |
| 166 | impl QueryKnob { |
| 167 | const fn as_str(self) -> &'static str { |
| 168 | match self { |
| 169 | Self::Recency => "recency", |
| 170 | Self::Domains => "domains", |
| 171 | Self::Locale => "locale", |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 177 | #[serde(tag = "kind", rename_all = "snake_case")] |
| 178 | pub(crate) enum DegradedReason { |
| 179 | BackendUnavailable { backend: BackendId }, |
| 180 | NoUsableResults { backend: BackendId }, |
| 181 | BackendFallback { from: BackendId, to: BackendId }, |
| 182 | ChallengeDetected { backend: BackendId }, |
| 183 | ScrapeFallback { from: BackendId, to: BackendId }, |
| 184 | KnobIgnored { knob: QueryKnob }, |
| 185 | PostFiltered { knob: QueryKnob }, |
| 186 | SynthesizedResults, |
| 187 | } |
| 188 | |
| 189 | impl DegradedReason { |
| 190 | #[must_use] |
| 191 | pub(crate) fn message(&self) -> String { |
| 192 | match self { |
| 193 | Self::BackendUnavailable { backend } => { |
| 194 | format!("{} was unavailable", backend.as_str()) |
| 195 | } |
| 196 | Self::NoUsableResults { backend } => { |
| 197 | format!("{} returned no usable results", backend.as_str()) |
| 198 | } |
| 199 | Self::BackendFallback { from, to } => format!( |
| 200 | "{} did not answer; tried {} next", |
| 201 | from.as_str(), |
| 202 | to.as_str() |
| 203 | ), |
| 204 | Self::ChallengeDetected { backend } => { |
| 205 | format!("{} returned a bot challenge", backend.as_str()) |
| 206 | } |
| 207 | Self::ScrapeFallback { from, to } => format!( |
| 208 | "{} returned no usable results; used {} fallback", |
| 209 | from.as_str(), |
| 210 | to.as_str() |
| 211 | ), |
| 212 | Self::KnobIgnored { knob } => { |
| 213 | format!("{} filter was not enforced by this backend", knob.as_str()) |
| 214 | } |
| 215 | Self::PostFiltered { knob } => { |
| 216 | format!("results were post-filtered by {}", knob.as_str()) |
| 217 | } |
| 218 | Self::SynthesizedResults => { |
| 219 | "results were synthesized by a model-backed search response".to_string() |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 226 | pub(crate) struct SearchResult { |
| 227 | pub(crate) rank: u8, |
| 228 | /// Session-scoped citation handle. Backends leave this empty; the shared |
| 229 | /// execution surface mints it before any result crosses the tool boundary. |
| 230 | pub(crate) ref_id: String, |
| 231 | pub(crate) title: String, |
| 232 | pub(crate) url: String, |
| 233 | #[serde(skip_serializing_if = "Option::is_none")] |
| 234 | pub(crate) snippet: Option<String>, |
| 235 | #[serde(skip_serializing_if = "Option::is_none")] |
| 236 | pub(crate) published: Option<String>, |
| 237 | pub(crate) domain: String, |
| 238 | } |
| 239 | |
| 240 | impl SearchResult { |
| 241 | #[must_use] |
| 242 | pub(crate) fn new( |
| 243 | rank: usize, |
| 244 | title: String, |
| 245 | url: String, |
| 246 | snippet: Option<String>, |
| 247 | published: Option<String>, |
| 248 | ) -> Self { |
| 249 | let domain = reqwest::Url::parse(&url) |
| 250 | .ok() |
| 251 | .and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase)) |
| 252 | .unwrap_or_default(); |
| 253 | Self { |
| 254 | rank: u8::try_from(rank.clamp(1, 255)).unwrap_or(u8::MAX), |
| 255 | ref_id: String::new(), |
| 256 | title, |
| 257 | url, |
| 258 | snippet, |
| 259 | published, |
| 260 | domain, |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | #[derive(Debug, Clone)] |
| 266 | pub(crate) struct BackendSearch { |
| 267 | pub(crate) backend: BackendId, |
| 268 | pub(crate) source: String, |
| 269 | pub(crate) backend_detail: Option<String>, |
| 270 | pub(crate) results: Vec<SearchResult>, |
| 271 | pub(crate) degraded: Vec<DegradedReason>, |
| 272 | pub(crate) note: Option<String>, |
| 273 | } |
| 274 | |
| 275 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 276 | pub(crate) struct SearchReceipt { |
| 277 | pub(crate) backend: BackendId, |
| 278 | #[serde(skip_serializing_if = "Option::is_none")] |
| 279 | pub(crate) backend_detail: Option<String>, |
| 280 | pub(crate) requested: SearchQuery, |
| 281 | pub(crate) capabilities: QueryCapabilities, |
| 282 | pub(crate) honored: HonoredQueryCapabilities, |
| 283 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 284 | pub(crate) degraded: Vec<DegradedReason>, |
| 285 | pub(crate) latency_ms: u32, |
| 286 | pub(crate) cache_hit: bool, |
| 287 | } |
| 288 | |
| 289 | impl SearchReceipt { |
| 290 | #[must_use] |
| 291 | pub(crate) fn warning(&self) -> Option<String> { |
| 292 | if self.degraded.is_empty() { |
| 293 | None |
| 294 | } else { |
| 295 | Some( |
| 296 | self.degraded |
| 297 | .iter() |
| 298 | .map(DegradedReason::message) |
| 299 | .collect::<Vec<_>>() |
| 300 | .join("; "), |
| 301 | ) |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 307 | pub(crate) struct SearchResponse { |
| 308 | pub(crate) query: String, |
| 309 | pub(crate) source: String, |
| 310 | pub(crate) count: usize, |
| 311 | pub(crate) message: String, |
| 312 | pub(crate) results: Vec<SearchResult>, |
| 313 | pub(crate) receipt: SearchReceipt, |
| 314 | } |
| 315 | |
| 316 | #[cfg(test)] |
| 317 | mod tests { |
| 318 | use super::*; |
| 319 | |
| 320 | #[test] |
| 321 | fn retrieval_defaults_are_coherent_across_search_and_fetch() { |
| 322 | use super::super::fetch::{DEFAULT_TIMEOUT, HARD_MAX_TIMEOUT}; |
| 323 | |
| 324 | // Search and fetch share one default and one hard-cap timeout so the |
| 325 | // two halves of the retrieval path behave identically by default. |
| 326 | assert_eq!( |
| 327 | u128::from(DEFAULT_SEARCH_TIMEOUT_MS), |
| 328 | DEFAULT_TIMEOUT.as_millis() |
| 329 | ); |
| 330 | assert_eq!( |
| 331 | u128::from(MAX_SEARCH_TIMEOUT_MS), |
| 332 | HARD_MAX_TIMEOUT.as_millis() |
| 333 | ); |
| 334 | assert!(DEFAULT_SEARCH_RESULTS <= usize::from(MAX_SEARCH_RESULTS)); |
| 335 | } |
| 336 | |
| 337 | #[test] |
| 338 | fn search_query_normalizes_domains_and_bounds_count() { |
| 339 | let query = SearchQuery::new( |
| 340 | "rust async".to_string(), |
| 341 | 99, |
| 342 | Some(Recency::Week), |
| 343 | vec![" WWW.Example.COM. ".to_string(), "example.com".to_string()], |
| 344 | Some(" en-US ".to_string()), |
| 345 | ); |
| 346 | |
| 347 | assert_eq!(query.max_results, 10); |
| 348 | assert_eq!(query.domains, vec!["example.com"]); |
| 349 | assert_eq!(query.locale.as_deref(), Some("en-US")); |
| 350 | assert_eq!(query.recency.map(Recency::days), Some(7)); |
| 351 | } |
| 352 | |
| 353 | #[test] |
| 354 | fn normalized_result_derives_domain_and_rank() { |
| 355 | let result = SearchResult::new( |
| 356 | 2, |
| 357 | "Example".to_string(), |
| 358 | "https://Docs.Example.com/path".to_string(), |
| 359 | Some("summary".to_string()), |
| 360 | None, |
| 361 | ); |
| 362 | |
| 363 | assert_eq!(result.rank, 2); |
| 364 | assert_eq!(result.domain, "docs.example.com"); |
| 365 | } |
| 366 | |
| 367 | #[test] |
| 368 | fn degraded_receipt_is_machine_readable_and_human_visible() { |
| 369 | let receipt = SearchReceipt { |
| 370 | backend: BackendId::DuckDuckGo, |
| 371 | backend_detail: None, |
| 372 | requested: SearchQuery::new( |
| 373 | "fresh result".to_string(), |
| 374 | 5, |
| 375 | Some(Recency::Day), |
| 376 | Vec::new(), |
| 377 | None, |
| 378 | ), |
| 379 | capabilities: QueryCapabilities::count_only(), |
| 380 | honored: HonoredQueryCapabilities { |
| 381 | max_results: true, |
| 382 | ..HonoredQueryCapabilities::default() |
| 383 | }, |
| 384 | degraded: vec![DegradedReason::KnobIgnored { |
| 385 | knob: QueryKnob::Recency, |
| 386 | }], |
| 387 | latency_ms: 4, |
| 388 | cache_hit: false, |
| 389 | }; |
| 390 | |
| 391 | let value = serde_json::to_value(&receipt).expect("receipt serializes"); |
| 392 | assert_eq!(value["backend"], "duckduckgo"); |
| 393 | assert_eq!(value["degraded"][0]["kind"], "knob_ignored"); |
| 394 | assert!(receipt.warning().expect("warning").contains("recency")); |
| 395 | } |
| 396 | |
| 397 | #[test] |
| 398 | fn scrape_fallback_receipt_preserves_backend_transitions() { |
| 399 | let receipt = SearchReceipt { |
| 400 | backend: BackendId::Bing, |
| 401 | backend_detail: None, |
| 402 | requested: SearchQuery::new("fallback query".to_string(), 5, None, Vec::new(), None), |
| 403 | capabilities: QueryCapabilities::count_only(), |
| 404 | honored: HonoredQueryCapabilities { |
| 405 | max_results: true, |
| 406 | ..HonoredQueryCapabilities::default() |
| 407 | }, |
| 408 | degraded: vec![ |
| 409 | DegradedReason::ChallengeDetected { |
| 410 | backend: BackendId::DuckDuckGo, |
| 411 | }, |
| 412 | DegradedReason::ScrapeFallback { |
| 413 | from: BackendId::DuckDuckGo, |
| 414 | to: BackendId::Bing, |
| 415 | }, |
| 416 | ], |
| 417 | latency_ms: 12, |
| 418 | cache_hit: false, |
| 419 | }; |
| 420 | |
| 421 | let value = serde_json::to_value(&receipt).expect("receipt serializes"); |
| 422 | assert_eq!(value["backend"], "bing"); |
| 423 | assert_eq!(value["degraded"][0]["kind"], "challenge_detected"); |
| 424 | assert_eq!(value["degraded"][0]["backend"], "duckduckgo"); |
| 425 | assert_eq!(value["degraded"][1]["kind"], "scrape_fallback"); |
| 426 | assert_eq!(value["degraded"][1]["from"], "duckduckgo"); |
| 427 | assert_eq!(value["degraded"][1]["to"], "bing"); |
| 428 | let warning = receipt.warning().expect("warning"); |
| 429 | assert!(warning.contains("bot challenge")); |
| 430 | assert!(warning.contains("used bing fallback")); |
| 431 | } |
| 432 | } |
| 433 |