mur_common/research_provider.rs
1//! The search providers `mur deep-research` can be given a key for.
2//!
3//! Lives in mur-common because BOTH ends need the same names and neither can
4//! see the other: `mur-core` owns the `mur deep-research secret` command that
5//! WRITES a key, and `mur-research-gateway` is the standalone binary that
6//! READS it. mur-core does not depend on the gateway crate (and must not —
7//! the gateway is deliberately dependency-light), so a provider list defined
8//! in either one would have to be duplicated in the other, and a config key
9//! spelled `serpapi_api_key_ref` on the write side and `serp_api_key_ref` on
10//! the read side would store a secret that is never found. One enum, both
11//! ends (CLAUDE.md rule 1).
12
13/// A web-search backend the gateway's `search` tool can use instead of
14/// scraping DuckDuckGo's keyless HTML endpoint.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub enum SearchProvider {
17 /// Brave Search API — the default and the only one that predates this
18 /// enum. Free tier covers a personal deep-research user.
19 Brave,
20 /// Tavily — search API built for LLM agents.
21 Tavily,
22 /// SerpApi — Google results via a scraping API.
23 SerpApi,
24 /// Firecrawl — search + page extraction.
25 Firecrawl,
26}
27
28/// The order the gateway tries configured providers in when the operator has
29/// not named one explicitly. Brave is first because it is the documented
30/// default and the only provider any existing install can already have a key
31/// for — adding three more must never silently move an existing user off the
32/// backend they configured.
33pub const PROVIDER_PREFERENCE: [SearchProvider; 4] = [
34 SearchProvider::Brave,
35 SearchProvider::Tavily,
36 SearchProvider::SerpApi,
37 SearchProvider::Firecrawl,
38];
39
40/// Keychain service name every MUR credential is filed under. Matches
41/// `mur-core`'s `sources::credentials::SERVICE`, and is the `mur` in the
42/// documented `keychain:mur/brave` ref.
43pub const KEYCHAIN_SERVICE: &str = "mur";
44
45impl SearchProvider {
46 /// Every provider, in preference order.
47 pub fn all() -> [SearchProvider; 4] {
48 PROVIDER_PREFERENCE
49 }
50
51 /// Lowercase identifier used in the CLI flag (`--brave`), the keychain
52 /// account, and the config key prefix. One slug drives all three so they
53 /// cannot drift apart.
54 pub fn slug(self) -> &'static str {
55 match self {
56 SearchProvider::Brave => "brave",
57 SearchProvider::Tavily => "tavily",
58 SearchProvider::SerpApi => "serpapi",
59 SearchProvider::Firecrawl => "firecrawl",
60 }
61 }
62
63 /// How the provider writes its own name, for anything a human reads.
64 pub fn display_name(self) -> &'static str {
65 match self {
66 SearchProvider::Brave => "Brave Search",
67 SearchProvider::Tavily => "Tavily",
68 SearchProvider::SerpApi => "SerpApi",
69 SearchProvider::Firecrawl => "Firecrawl",
70 }
71 }
72
73 /// `research_gateway.<this>` — the config.yaml key holding a `SecretRef`
74 /// string rather than the secret itself.
75 pub fn config_key_ref(self) -> String {
76 format!("{}_api_key_ref", self.slug())
77 }
78
79 /// `research_gateway.<this>` — the legacy plaintext key. Still read (an
80 /// existing `brave_api_key` must keep working) but never written by
81 /// `mur deep-research secret`.
82 pub fn config_key_plain(self) -> String {
83 format!("{}_api_key", self.slug())
84 }
85
86 /// Environment override, highest precedence of all.
87 pub fn env_var(self) -> String {
88 format!("MUR_RESEARCH_{}_KEY", self.slug().to_ascii_uppercase())
89 }
90
91 /// Keychain account this provider's key is stored under.
92 pub fn keychain_account(self) -> &'static str {
93 self.slug()
94 }
95
96 /// The `SecretRef` string written into config.yaml — the reference is safe
97 /// to commit and log; the key itself never enters the file.
98 pub fn keychain_ref(self) -> String {
99 format!("keychain:{KEYCHAIN_SERVICE}/{}", self.keychain_account())
100 }
101
102 /// Where a user gets a key. Printed by the setup command, because "get an
103 /// API key" without a URL is a scavenger hunt.
104 pub fn signup_url(self) -> &'static str {
105 match self {
106 SearchProvider::Brave => "https://brave.com/search/api/",
107 SearchProvider::Tavily => "https://app.tavily.com/",
108 SearchProvider::SerpApi => "https://serpapi.com/manage-api-key",
109 SearchProvider::Firecrawl => "https://www.firecrawl.dev/app/api-keys",
110 }
111 }
112}
113
114impl std::fmt::Display for SearchProvider {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.write_str(self.slug())
117 }
118}
119
120impl std::str::FromStr for SearchProvider {
121 type Err = String;
122
123 /// Accepts the slug in any case, plus the spellings a user is likely to
124 /// type by hand (`serp-api`, `serp_api`) — a rejected name here costs a
125 /// round trip for no safety gain.
126 fn from_str(s: &str) -> Result<Self, Self::Err> {
127 let normalized = s.trim().to_ascii_lowercase().replace(['-', '_'], "");
128 match normalized.as_str() {
129 "brave" | "bravesearch" => Ok(SearchProvider::Brave),
130 "tavily" => Ok(SearchProvider::Tavily),
131 "serpapi" | "serp" => Ok(SearchProvider::SerpApi),
132 "firecrawl" => Ok(SearchProvider::Firecrawl),
133 _ => Err(format!(
134 "unknown search provider '{s}' — expected one of: {}",
135 SearchProvider::all()
136 .iter()
137 .map(|p| p.slug())
138 .collect::<Vec<_>>()
139 .join(", ")
140 )),
141 }
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use std::str::FromStr;
149
150 /// The whole reason this enum is in mur-common: the writer (mur-core) and
151 /// the reader (the gateway) must derive the SAME strings. Pin them, so a
152 /// rename on one side fails here instead of silently storing a key the
153 /// gateway never looks for.
154 #[test]
155 fn config_keys_and_refs_are_derived_from_one_slug() {
156 assert_eq!(SearchProvider::Brave.config_key_ref(), "brave_api_key_ref");
157 assert_eq!(SearchProvider::Brave.config_key_plain(), "brave_api_key");
158 assert_eq!(SearchProvider::Brave.env_var(), "MUR_RESEARCH_BRAVE_KEY");
159 assert_eq!(SearchProvider::Brave.keychain_ref(), "keychain:mur/brave");
160
161 assert_eq!(
162 SearchProvider::SerpApi.config_key_ref(),
163 "serpapi_api_key_ref"
164 );
165 assert_eq!(
166 SearchProvider::Firecrawl.env_var(),
167 "MUR_RESEARCH_FIRECRAWL_KEY"
168 );
169 }
170
171 /// `brave_api_key_ref` / `MUR_RESEARCH_BRAVE_KEY` / `keychain:mur/brave`
172 /// already exist in shipped configs and in the gateway's operator advice.
173 /// Deriving them from the enum must reproduce them EXACTLY, or this change
174 /// silently orphans every existing Brave key.
175 #[test]
176 fn brave_spellings_match_what_already_ships() {
177 let cfg = SearchProvider::Brave;
178 // As printed by fetcher.rs's search_blocked_error operator advice.
179 assert_eq!(cfg.config_key_ref(), "brave_api_key_ref");
180 assert_eq!(cfg.keychain_ref(), "keychain:mur/brave");
181 // As read by config.rs's ENV_BRAVE_KEY.
182 assert_eq!(cfg.env_var(), "MUR_RESEARCH_BRAVE_KEY");
183 }
184
185 #[test]
186 fn parses_case_and_punctuation_variants() {
187 assert_eq!(
188 SearchProvider::from_str("Brave").unwrap(),
189 SearchProvider::Brave
190 );
191 assert_eq!(
192 SearchProvider::from_str(" TAVILY ").unwrap(),
193 SearchProvider::Tavily
194 );
195 // A user typing the product name by hand gets all three spellings.
196 for s in ["serpapi", "SerpApi", "serp-api", "serp_api"] {
197 assert_eq!(
198 SearchProvider::from_str(s).unwrap(),
199 SearchProvider::SerpApi,
200 "failed to parse {s}"
201 );
202 }
203 }
204
205 #[test]
206 fn unknown_provider_names_the_valid_choices() {
207 let err = SearchProvider::from_str("google").unwrap_err();
208 assert!(err.contains("google"), "{err}");
209 // The message must list what IS accepted, not just reject.
210 for p in SearchProvider::all() {
211 assert!(err.contains(p.slug()), "{err} is missing {p}");
212 }
213 }
214
215 /// Brave must stay first: it is the only provider an existing install can
216 /// already hold a key for, and auto-selection walks this order.
217 #[test]
218 fn brave_leads_the_preference_order() {
219 assert_eq!(PROVIDER_PREFERENCE[0], SearchProvider::Brave);
220 assert_eq!(SearchProvider::all().len(), 4);
221 }
222
223 #[test]
224 fn slugs_are_unique() {
225 let mut slugs: Vec<_> = SearchProvider::all().iter().map(|p| p.slug()).collect();
226 slugs.sort_unstable();
227 slugs.dedup();
228 assert_eq!(slugs.len(), 4, "two providers share a slug");
229 }
230}