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}