Skip to main content

zeph_tools/search/
provider.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Search backend contract and dispatch.
5
6use zeph_common::secret::Secret;
7use zeph_config::tools::SearchConfig;
8
9use super::brave::BraveSearchProvider;
10
11/// One ranked search result.
12#[derive(Debug, Clone, serde::Serialize)]
13pub struct SearchResult {
14    /// Result page title, as returned by the search backend.
15    pub title: String,
16    /// Result page URL. Text only — never auto-fetched by the search tool itself.
17    pub url: String,
18    /// Short excerpt/snippet from the result page.
19    pub snippet: String,
20}
21
22/// Search-backend error taxonomy — mapped to [`crate::executor::ToolError`] at the
23/// executor boundary; never surfaced raw to the LLM.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum SearchError {
27    /// No API key configured for a backend that requires one.
28    #[error("no API key configured for backend {backend}")]
29    MissingApiKey {
30        /// Name of the backend that requires a key (see [`SearchProvider::name`]).
31        backend: &'static str,
32    },
33    /// The backend returned a non-success HTTP status.
34    #[error("HTTP error {status}: {message}")]
35    Http {
36        /// HTTP status code.
37        status: u16,
38        /// Human-readable status/error message.
39        message: String,
40    },
41    /// The request did not complete within the configured timeout.
42    #[error("request timed out")]
43    Timeout,
44    /// The request was blocked by policy (SSRF, denylist, or backend rate limiting).
45    #[error("blocked: {reason}")]
46    Blocked {
47        /// Human-readable block reason.
48        reason: String,
49        /// HTTP status code that triggered the block, when applicable (e.g. `429` for
50        /// backend rate-limiting). `None` for non-HTTP blocks (SSRF, denylist).
51        status: Option<u16>,
52    },
53    /// The backend response body could not be parsed.
54    #[error("failed to parse response: {0}")]
55    Parse(String),
56    /// Catch-all for backend-specific failures not covered by the other variants.
57    #[error("provider error: {0}")]
58    Provider(String),
59}
60
61/// Contract for a query-based web search backend.
62///
63/// Implementors guarantee they issue at most one HTTPS call per [`search`](Self::search)
64/// invocation, to the exact URL returned by [`endpoint`](Self::endpoint), using the
65/// `client` handed in by the caller rather than building their own.
66///
67/// # Design note: caller-supplied client
68///
69/// [`WebSearchExecutor`](crate::search::WebSearchExecutor) — not the provider — owns SSRF
70/// validation (`zeph_common::net::resolve_and_validate`) and addr-pinning
71/// (`reqwest::ClientBuilder::resolve_to_addrs`), mirroring `WebScrapeExecutor`. The
72/// resulting pinned [`reqwest::Client`] is passed into `search()` so a provider must use it
73/// as-is rather than building its own unpinned client (INVARIANT-2, spec 006-1-web-search
74/// §3.4/§4) — this is a contract implementors must uphold, not a compile-time guarantee.
75///
76/// # Examples
77///
78/// ```rust,no_run
79/// use zeph_tools::search::SearchBackend;
80/// use zeph_tools::SearchConfig;
81///
82/// let backend = SearchBackend::from_config(&SearchConfig::default(), 1_048_576, None);
83/// assert!(backend.is_err()); // Brave requires an API key
84/// ```
85pub trait SearchProvider: Send + Sync {
86    /// Issue a single search query via `client` and return ranked results.
87    ///
88    /// `client` is pre-validated and addr-pinned by the caller (see the trait-level
89    /// design note) — implementors must use it as-is, never build their own client.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`SearchError`] on missing key, HTTP failure, timeout, or response parse
94    /// failure.
95    fn search(
96        &self,
97        client: &reqwest::Client,
98        query: &str,
99        limit: usize,
100    ) -> impl Future<Output = Result<Vec<SearchResult>, SearchError>> + Send;
101
102    /// The fixed, operator-configured endpoint this provider calls.
103    ///
104    /// Used by [`crate::search::WebSearchExecutor`] to run SSRF/denylist validation and
105    /// addr-pinning *before* the request is issued.
106    fn endpoint(&self) -> &url::Url;
107
108    /// Stable provider name for audit/egress records and error messages (e.g. `"brave"`).
109    fn name(&self) -> &'static str;
110}
111
112/// Concrete backend dispatch.
113///
114/// An enum (not `Box<dyn ErasedSearchProvider>`) keeps [`WebSearchExecutor`](crate::search::WebSearchExecutor)
115/// non-generic and avoids erased-async-trait boilerplate for a small, closed backend set.
116/// New backends are new variants.
117#[derive(Debug)]
118#[non_exhaustive]
119pub enum SearchBackend {
120    /// Brave Search API (v1 default backend).
121    Brave(BraveSearchProvider),
122}
123
124impl SearchBackend {
125    /// Resolve a backend from config plus an optional pre-fetched API key.
126    ///
127    /// `max_body_bytes` caps the response body size a provider will read (from
128    /// `[tools.scrape].max_body_bytes` — the search tool has no independent body-size
129    /// config, mirroring its `denied_domains`/`ipi_filter_threshold` inheritance).
130    ///
131    /// Returns `Err` for a keyed backend (e.g. Brave) with no key. A future keyless
132    /// backend (e.g. `SearXNG`) must be constructible with `api_key: None` — callers gate
133    /// tool availability on this function's success, not on "key present" (see FR-002).
134    ///
135    /// # Errors
136    ///
137    /// Returns [`SearchError::MissingApiKey`] when the selected backend requires a key
138    /// and none was supplied, or [`SearchError::Provider`] when `cfg.endpoint` is not a
139    /// valid URL or `cfg.backend` names an unknown backend.
140    pub fn from_config(
141        cfg: &SearchConfig,
142        max_body_bytes: usize,
143        api_key: Option<Secret>,
144    ) -> Result<Self, SearchError> {
145        match cfg.backend.as_str() {
146            "brave" => Ok(Self::Brave(BraveSearchProvider::new(
147                cfg,
148                max_body_bytes,
149                api_key,
150            )?)),
151            other => Err(SearchError::Provider(format!(
152                "unknown search backend: {other}"
153            ))),
154        }
155    }
156
157    /// See [`SearchProvider::search`].
158    ///
159    /// # Errors
160    ///
161    /// Returns [`SearchError`] on missing key, HTTP failure, timeout, or response parse
162    /// failure.
163    pub async fn search(
164        &self,
165        client: &reqwest::Client,
166        query: &str,
167        limit: usize,
168    ) -> Result<Vec<SearchResult>, SearchError> {
169        match self {
170            Self::Brave(provider) => provider.search(client, query, limit).await,
171        }
172    }
173
174    /// See [`SearchProvider::endpoint`].
175    #[must_use]
176    pub fn endpoint(&self) -> &url::Url {
177        match self {
178            Self::Brave(provider) => provider.endpoint(),
179        }
180    }
181
182    /// See [`SearchProvider::name`].
183    #[must_use]
184    pub fn name(&self) -> &'static str {
185        match self {
186            Self::Brave(provider) => provider.name(),
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn from_config_unknown_backend_errors() {
197        let cfg = SearchConfig {
198            backend: "unknown".to_owned(),
199            ..SearchConfig::default()
200        };
201        let err = SearchBackend::from_config(&cfg, 1_048_576, None).unwrap_err();
202        assert!(matches!(err, SearchError::Provider(_)));
203    }
204
205    #[test]
206    fn from_config_brave_without_key_errors() {
207        let cfg = SearchConfig::default();
208        let err = SearchBackend::from_config(&cfg, 1_048_576, None).unwrap_err();
209        assert!(matches!(
210            err,
211            SearchError::MissingApiKey { backend: "brave" }
212        ));
213    }
214
215    #[test]
216    fn from_config_brave_with_key_succeeds() {
217        let cfg = SearchConfig::default();
218        let backend =
219            SearchBackend::from_config(&cfg, 1_048_576, Some(Secret::new("test-key"))).unwrap();
220        assert_eq!(backend.name(), "brave");
221    }
222}