| 1 | //! Web-search provider configuration types. |
| 2 | //! |
| 3 | //! Self-contained `[search]` table types extracted verbatim from `config.rs`. |
| 4 | //! Re-exported from `crate::config` via `pub use search::*;` so existing |
| 5 | //! `crate::config::SearchProvider` (and sibling) paths resolve unchanged |
| 6 | //! (#3311). |
| 7 | |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | |
| 10 | /// Search provider enumeration — selects the first backend `web_search` uses. |
| 11 | /// API-backed providers may visibly degrade through DuckDuckGo → Bing after |
| 12 | /// runtime failure or an empty result. Configuration and |
| 13 | /// network-policy errors fail closed without crossing providers. |
| 14 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] |
| 15 | #[serde(rename_all = "snake_case")] |
| 16 | pub enum SearchProvider { |
| 17 | /// Bing HTML scraping. No API key needed. |
| 18 | Bing, |
| 19 | /// DuckDuckGo HTML scraping with Bing fallback. No API key needed. |
| 20 | #[serde(alias = "duckduckgo")] |
| 21 | DuckDuckGo, |
| 22 | /// Firecrawl Search API. Works keyless on Firecrawl Cloud with a bounded |
| 23 | /// per-IP quota; `[search] api_key` or `FIRECRAWL_API_KEY` raises limits. |
| 24 | #[default] |
| 25 | Firecrawl, |
| 26 | /// Tavily AI Search API (<https://tavily.com>). Requires api_key. |
| 27 | Tavily, |
| 28 | /// Bocha AI Search API (<https://bochaai.com>). Requires api_key. |
| 29 | Bocha, |
| 30 | /// Metaso AI Search API (<https://metaso.cn>). Requires `[search] api_key` |
| 31 | /// or the `METASO_API_KEY` env var. |
| 32 | #[serde(alias = "metaso")] |
| 33 | Metaso, |
| 34 | /// SearXNG JSON search API. Requires a trusted/self-hosted `base_url`. |
| 35 | #[serde(alias = "searx", alias = "searx-ng", alias = "searx_ng")] |
| 36 | Searxng, |
| 37 | /// Baidu AI Search API (<https://qianfan.baidubce.com>). Requires api_key. |
| 38 | #[serde( |
| 39 | alias = "baidu-search", |
| 40 | alias = "baidu_ai_search", |
| 41 | alias = "baidu_search", |
| 42 | alias = "baidu-ai-search" |
| 43 | )] |
| 44 | Baidu, |
| 45 | /// Volcengine Ark web_search via Responses API. Requires api_key. |
| 46 | /// Free tier: 20K queries/month per API key. Falls back to |
| 47 | /// `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` |
| 48 | /// env vars when `[search] api_key` is not set. |
| 49 | #[serde( |
| 50 | alias = "volcengine", |
| 51 | alias = "ark", |
| 52 | alias = "volc", |
| 53 | alias = "volcengine-ark", |
| 54 | alias = "volcengine_ark", |
| 55 | alias = "volc-ark" |
| 56 | )] |
| 57 | Volcengine, |
| 58 | /// Sofya web search API (<https://sofya.co>). Requires api_key |
| 59 | /// (`ay_live_...`). Returns full extracted page content rather than |
| 60 | /// snippets; falls back to the `SOFYA_API_KEY` env var when |
| 61 | /// `[search] api_key` is not set. |
| 62 | Sofya, |
| 63 | /// Serply Google search API (<https://serply.io>). Requires api_key; |
| 64 | /// returns Google organic results with snippets. Falls back to the |
| 65 | /// `SERPLY_API_KEY` env var when `[search] api_key` is not set. |
| 66 | Serply, |
| 67 | } |
| 68 | |
| 69 | impl SearchProvider { |
| 70 | #[must_use] |
| 71 | pub fn parse(value: &str) -> Option<Self> { |
| 72 | match value.trim().to_ascii_lowercase().as_str() { |
| 73 | "bing" => Some(Self::Bing), |
| 74 | "duckduckgo" | "duck-duck-go" | "duck_duck_go" | "ddg" => Some(Self::DuckDuckGo), |
| 75 | "firecrawl" | "fire-crawl" | "fire_crawl" => Some(Self::Firecrawl), |
| 76 | "tavily" => Some(Self::Tavily), |
| 77 | "bocha" => Some(Self::Bocha), |
| 78 | "metaso" => Some(Self::Metaso), |
| 79 | "searxng" | "searx" | "searx-ng" | "searx_ng" => Some(Self::Searxng), |
| 80 | "baidu" | "baidu-search" | "baidu_search" | "baidu-ai-search" | "baidu_ai_search" => { |
| 81 | Some(Self::Baidu) |
| 82 | } |
| 83 | "volcengine" | "ark" | "volc" | "volcengine-ark" => Some(Self::Volcengine), |
| 84 | "sofya" => Some(Self::Sofya), |
| 85 | "serply" => Some(Self::Serply), |
| 86 | _ => None, |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | #[must_use] |
| 91 | pub fn as_str(self) -> &'static str { |
| 92 | match self { |
| 93 | Self::Bing => "bing", |
| 94 | Self::DuckDuckGo => "duckduckgo", |
| 95 | Self::Firecrawl => "firecrawl", |
| 96 | Self::Tavily => "tavily", |
| 97 | Self::Bocha => "bocha", |
| 98 | Self::Metaso => "metaso", |
| 99 | Self::Searxng => "searxng", |
| 100 | Self::Baidu => "baidu", |
| 101 | Self::Volcengine => "volcengine", |
| 102 | Self::Sofya => "sofya", |
| 103 | Self::Serply => "serply", |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | #[must_use] |
| 108 | pub fn names_hint() -> &'static str { |
| 109 | "bing, duckduckgo, firecrawl, tavily, bocha, metaso, searxng, baidu, volcengine, sofya, serply" |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 114 | pub enum SearchProviderSource { |
| 115 | Default, |
| 116 | Config, |
| 117 | EnvOverride, |
| 118 | /// Autodetected from a Tavily key signal: `TAVILY_API_KEY`, or a generic |
| 119 | /// `[search] api_key` / `CODEWHALE_SEARCH_API_KEY` value in the `tvly-` |
| 120 | /// family. Runtime-only — resolution never writes `[search] provider`. |
| 121 | TavilyKey, |
| 122 | } |
| 123 | |
| 124 | impl SearchProviderSource { |
| 125 | /// One honest source token for doctor, `/config`, and the runtime GET |
| 126 | /// route. `tavily key` names where the signal came from without claiming a |
| 127 | /// disk pin or naming `TAVILY_API_KEY` when the winner was a `tvly-` |
| 128 | /// generic key. |
| 129 | #[must_use] |
| 130 | pub fn as_str(self) -> &'static str { |
| 131 | match self { |
| 132 | Self::Default => "default", |
| 133 | Self::Config => "config", |
| 134 | Self::EnvOverride => "env override", |
| 135 | Self::TavilyKey => "tavily key", |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// Tavily issues keys in the `tvly-` family; the Tavily error copy already |
| 141 | /// says so. Applied to **generic** keys only — a dedicated `TAVILY_API_KEY` |
| 142 | /// is honored as-is, however it is shaped. |
| 143 | #[must_use] |
| 144 | pub fn looks_like_tavily_key(value: &str) -> bool { |
| 145 | value.trim().starts_with("tvly-") |
| 146 | } |
| 147 | |
| 148 | /// `TAVILY_API_KEY`, read at request time. Deliberately never merged into |
| 149 | /// [`SearchConfig::api_key`]: that generic slot is shared by every provider, |
| 150 | /// so merging would hand a Tavily key to Firecrawl (or the reverse) the moment |
| 151 | /// the operator pins a different provider. |
| 152 | #[must_use] |
| 153 | pub fn tavily_env_key() -> Option<String> { |
| 154 | std::env::var("TAVILY_API_KEY") |
| 155 | .ok() |
| 156 | .map(|value| value.trim().to_string()) |
| 157 | .filter(|value| !value.is_empty()) |
| 158 | } |
| 159 | |
| 160 | /// The key a Tavily request should send. |
| 161 | /// |
| 162 | /// Dedicated env wins over the generic `[search] api_key` so resolution and |
| 163 | /// the request agree on one key: `[search] api_key` is shared, and a |
| 164 | /// Firecrawl `fc-` / sentinel generic value must never be POSTed to |
| 165 | /// `api.tavily.com`. The generic fallback is prefix-gated, which is what |
| 166 | /// stops `CODEWHALE_SEARCH_API_KEY=doctor-offline-search-sentinel` from |
| 167 | /// autodetecting Tavily. |
| 168 | #[must_use] |
| 169 | pub fn tavily_key_from(search_api_key: Option<&str>) -> Option<String> { |
| 170 | if let Some(env_key) = tavily_env_key() { |
| 171 | return Some(env_key); |
| 172 | } |
| 173 | search_api_key |
| 174 | .map(str::trim) |
| 175 | .filter(|value| !value.is_empty() && looks_like_tavily_key(value)) |
| 176 | .map(str::to_string) |
| 177 | } |
| 178 | |
| 179 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 180 | pub struct SearchProviderResolution { |
| 181 | pub provider: SearchProvider, |
| 182 | pub source: SearchProviderSource, |
| 183 | } |
| 184 | |
| 185 | /// Web search provider configuration (`[search]` table in config.toml). |
| 186 | #[derive(Debug, Clone, Deserialize, Default)] |
| 187 | pub struct SearchConfig { |
| 188 | /// Search provider. Default: keyless `firecrawl`. |
| 189 | #[serde(default)] |
| 190 | pub provider: Option<SearchProvider>, |
| 191 | /// Optional search endpoint. With `duckduckgo`, this is a |
| 192 | /// DuckDuckGo-compatible HTML endpoint. With `searxng`, this is the trusted |
| 193 | /// SearXNG instance root or `/search` endpoint. |
| 194 | #[serde(default)] |
| 195 | pub base_url: Option<String>, |
| 196 | /// Optional for Firecrawl; required for Tavily, Bocha, Metaso, Baidu, Volcengine, Sofya, or Serply. |
| 197 | /// Metaso also falls back to the `METASO_API_KEY` env var. |
| 198 | /// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var. |
| 199 | /// Serply also falls back to the `SERPLY_API_KEY` env var. |
| 200 | /// Volcengine also falls back to `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` env vars. |
| 201 | /// |
| 202 | /// This slot is shared across providers. `TAVILY_API_KEY` is **not** |
| 203 | /// merged into it — Tavily reads its dedicated env at request time |
| 204 | /// ([`tavily_key_from`]) so pinning another provider never forwards a |
| 205 | /// Tavily key, and a `tvly-` value here can autodetect Tavily without a |
| 206 | /// disk write. |
| 207 | #[serde(default)] |
| 208 | pub api_key: Option<String>, |
| 209 | } |
| 210 |