Skip to main content

leviath_cli/commands/setup/
catalog.rs

1//! The providers `lev setup` can configure, and how each one is configured.
2//!
3//! This table is what keeps the wizard a pick-list rather than a fixed march
4//! through every provider (asking for four API keys whether or not the user
5//! has them) ending in a free-text `default_provider` with no validation -
6//! where a typo produces a config that only fails at the first agent run. The
7//! wizard shows the table as a pick-list, and the default-provider choice is a
8//! radio over what was actually configured.
9//!
10//! Everything a provider needs to differ by lives here as data, so adding one
11//! is a table entry rather than another branch in the wizard.
12
13use crate::config::Config;
14
15/// What a provider needs from the user.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Credential {
18    /// An API key, entered masked.
19    ApiKey,
20    /// A base URL, with a working default.
21    BaseUrl,
22    /// Nothing - the provider is enabled by selecting it. Claude Code
23    /// authenticates through its own CLI.
24    None,
25}
26
27/// One configurable provider.
28#[derive(Debug, Clone)]
29pub struct Provider {
30    /// Registry name. Must match what `provider_creds_from_config` builds and
31    /// what a blueprint's `models = [{ provider = ... }]` names.
32    pub id: &'static str,
33    /// Name to show.
34    pub display: &'static str,
35    /// One line on what picking this means.
36    pub blurb: &'static str,
37    /// What to ask for.
38    pub credential: Credential,
39    /// Placeholder shown in the empty field.
40    pub hint: &'static str,
41    /// Environment variable this credential is also read from, so the wizard
42    /// can say "already in your environment" instead of asking again.
43    pub env_var: Option<&'static str>,
44    /// Where to get a credential, opened on request.
45    pub signup_url: Option<&'static str>,
46}
47
48/// Every provider the wizard offers, in the order it offers them.
49pub fn providers() -> Vec<Provider> {
50    vec![
51        Provider {
52            id: "anthropic",
53            display: "Anthropic",
54            blurb: "Claude models. The default for every shipped blueprint.",
55            credential: Credential::ApiKey,
56            hint: "sk-ant-...",
57            env_var: Some("ANTHROPIC_API_KEY"),
58            signup_url: Some("https://console.anthropic.com/settings/keys"),
59        },
60        Provider {
61            id: "openai",
62            display: "OpenAI",
63            blurb: "GPT models.",
64            credential: Credential::ApiKey,
65            hint: "sk-...",
66            env_var: Some("OPENAI_API_KEY"),
67            signup_url: Some("https://platform.openai.com/api-keys"),
68        },
69        Provider {
70            id: "google",
71            display: "Google (Gemini)",
72            blurb: "Gemini models.",
73            credential: Credential::ApiKey,
74            hint: "AIza...",
75            env_var: Some("GOOGLE_API_KEY"),
76            signup_url: Some("https://aistudio.google.com/app/apikey"),
77        },
78        Provider {
79            id: "openrouter",
80            display: "OpenRouter",
81            blurb: "One key, many vendors' models.",
82            credential: Credential::ApiKey,
83            hint: "sk-or-...",
84            env_var: Some("OPENROUTER_API_KEY"),
85            signup_url: Some("https://openrouter.ai/keys"),
86        },
87        Provider {
88            id: "ollama",
89            display: "Ollama (local)",
90            blurb: "Models running on this machine. No key needed.",
91            credential: Credential::BaseUrl,
92            hint: DEFAULT_OLLAMA_URL,
93            env_var: Some("OLLAMA_HOST"),
94            signup_url: Some("https://ollama.com/download"),
95        },
96        Provider {
97            id: "claude-code",
98            display: "Claude Code transport",
99            blurb: "Runs on your Claude subscription instead of an API key. \
100                    ⚠️ May conflict with Anthropic's terms for third-party \
101                    apps. The CLI adds ~130 tokens of its own context to every \
102                    call, including your account email. This cannot be \
103                    disabled.",
104            credential: Credential::None,
105            hint: "",
106            env_var: None,
107            signup_url: None,
108        },
109    ]
110}
111
112/// Ollama's default endpoint, and the value the wizard treats as "unset" so a
113/// user who leaves it alone gets the built-in default rather than a pinned copy
114/// of it in their config.
115pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
116
117/// Local inference realistically serves one model at a time, so the default
118/// concurrency for an Ollama-first setup is 1 rather than the usual 8: eight
119/// concurrent requests against one Ollama instance queue and thrash rather than
120/// going faster.
121pub const OLLAMA_MAX_CONCURRENT_INFERENCES: usize = 1;
122
123/// Read a provider's currently-configured credential out of a config.
124///
125/// Note `openrouter_api_key` and `ollama_base_url` sit at the top level of
126/// `Config` while the other three live under `[providers]` - a historical split
127/// this function hides from everything else.
128pub fn stored_credential(config: &Config, id: &str) -> Option<String> {
129    match id {
130        "anthropic" => config.providers.anthropic_api_key.clone(),
131        "openai" => config.providers.openai_api_key.clone(),
132        "google" => config.providers.google_api_key.clone(),
133        "openrouter" => config.openrouter_api_key.clone(),
134        "ollama" => config.ollama_base_url.clone(),
135        _ => None,
136    }
137}
138
139/// Write a provider's credential into a config. `None` clears it.
140pub fn set_credential(config: &mut Config, id: &str, value: Option<String>) {
141    match id {
142        "anthropic" => config.providers.anthropic_api_key = value,
143        "openai" => config.providers.openai_api_key = value,
144        "google" => config.providers.google_api_key = value,
145        "openrouter" => config.openrouter_api_key = value,
146        "ollama" => config.ollama_base_url = value,
147        // `claude-code` is a boolean, handled by the selection itself, and an
148        // unknown id has nowhere to go.
149        _ => {}
150    }
151}
152
153/// Whether a provider counts as configured in this config: it has a credential,
154/// or it needs none and is switched on.
155pub fn is_configured(config: &Config, id: &str) -> bool {
156    match id {
157        "claude-code" => config.providers.claude_code_enabled,
158        // Ollama is always usable at its default endpoint, but "configured"
159        // here means "the user chose it", which is the stored URL.
160        _ => stored_credential(config, id).is_some(),
161    }
162}
163
164/// Redact a credential for display.
165///
166/// Delegates to `leviath_core::secrets::redact`, which keeps the **last** four
167/// characters. Showing the *first eight* would give a different answer from
168/// the HTTP logger's, and keep the wrong half: API keys are structured at
169/// the front, so `sk-ant-a`, `sk-proj-` and `ghp_…` identify the issuer and, on
170/// a short token, expose a meaningful fraction of the value. A suffix is
171/// unstructured and just as good for "is this the key I think it is".
172///
173/// Kept as a named wrapper rather than replacing every call site, so this
174/// module's UI code reads the same and there is one place to look if the
175/// wizard ever needs a different presentation.
176pub fn redact(key: &str) -> String {
177    leviath_core::redact(key)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    // ─── the table ──────────────────────────────────────────────────────────
185
186    #[test]
187    fn every_provider_has_a_distinct_id_and_is_described() {
188        let all = providers();
189        let mut ids: Vec<&str> = all.iter().map(|p| p.id).collect();
190        ids.sort_unstable();
191        let total = ids.len();
192        ids.dedup();
193        assert_eq!(total, ids.len(), "duplicate provider ids");
194
195        for p in &all {
196            assert!(!p.display.is_empty(), "provider {} has no label", p.id);
197            assert!(!p.blurb.is_empty(), "provider {} has no blurb", p.id);
198        }
199    }
200
201    #[test]
202    fn every_api_key_provider_names_its_env_var_and_a_place_to_get_one() {
203        // Both drive real behaviour: the env var lets the wizard say "already
204        // in your environment" instead of asking again, and the URL backs the
205        // "open the signup page" key.
206        for p in providers()
207            .iter()
208            .filter(|p| p.credential == Credential::ApiKey)
209        {
210            assert!(p.env_var.is_some(), "{} has no env var", p.id);
211            assert!(p.signup_url.is_some(), "{} has no signup URL", p.id);
212            assert!(!p.hint.is_empty(), "{} has no placeholder", p.id);
213        }
214    }
215
216    #[test]
217    fn the_table_covers_every_credential_kind() {
218        let all = providers();
219        assert!(all.iter().any(|p| p.credential == Credential::ApiKey));
220        assert!(all.iter().any(|p| p.credential == Credential::BaseUrl));
221        assert!(all.iter().any(|p| p.credential == Credential::None));
222    }
223
224    #[test]
225    fn the_default_provider_is_in_the_table() {
226        // Otherwise a fresh config would name a provider the wizard can't
227        // configure - which is exactly how a free-text prompt once went wrong.
228        let config = Config::default();
229        assert!(
230            providers().iter().any(|p| p.id == config.default_provider),
231            "default_provider {} is not offered by the wizard",
232            config.default_provider
233        );
234    }
235
236    #[test]
237    fn claude_code_states_its_privacy_cost_up_front() {
238        // Offered, never chosen for the user, and never without the caveat.
239        let all = providers();
240        let cc = all
241            .iter()
242            .find(|p| p.id == "claude-code")
243            .expect("the transport is offered");
244        assert!(cc.blurb.contains("email"));
245        assert!(cc.blurb.contains("cannot be disabled"));
246        // Enabling it is a terms decision as much as a privacy one.
247        assert!(cc.blurb.contains("terms"));
248        assert_eq!(cc.credential, Credential::None);
249        assert!(!Config::default().providers.claude_code_enabled);
250    }
251
252    // ─── credential accessors ───────────────────────────────────────────────
253
254    #[test]
255    fn every_provider_with_a_credential_round_trips_through_the_config() {
256        // Catches the top-level/`[providers]` split silently dropping a field.
257        for p in providers()
258            .iter()
259            .filter(|p| p.credential != Credential::None)
260        {
261            let mut config = Config::default();
262            assert!(
263                stored_credential(&config, p.id).is_none(),
264                "{} starts set",
265                p.id
266            );
267
268            set_credential(&mut config, p.id, Some("value".to_string()));
269            assert_eq!(
270                stored_credential(&config, p.id).as_deref(),
271                Some("value"),
272                "{} did not round trip",
273                p.id
274            );
275            assert!(is_configured(&config, p.id), "{} reads unconfigured", p.id);
276
277            set_credential(&mut config, p.id, None);
278            assert!(
279                stored_credential(&config, p.id).is_none(),
280                "{} did not clear",
281                p.id
282            );
283            assert!(!is_configured(&config, p.id));
284        }
285    }
286
287    #[test]
288    fn an_unknown_provider_id_stores_nothing_and_reads_back_nothing() {
289        let mut config = Config::default();
290        set_credential(&mut config, "not-a-provider", Some("x".to_string()));
291
292        assert!(stored_credential(&config, "not-a-provider").is_none());
293        assert!(!is_configured(&config, "not-a-provider"));
294    }
295
296    #[test]
297    fn claude_code_is_configured_by_its_flag_not_a_credential() {
298        let mut config = Config::default();
299        assert!(!is_configured(&config, "claude-code"));
300        // It has no credential slot, so writing one must not make it look on.
301        set_credential(&mut config, "claude-code", Some("x".to_string()));
302        assert!(!is_configured(&config, "claude-code"));
303
304        config.providers.claude_code_enabled = true;
305        assert!(is_configured(&config, "claude-code"));
306    }
307
308    // ─── redact ─────────────────────────────────────────────────────────────
309
310    /// The wizard shows the last four characters, matching the HTTP logger.
311    /// Showing the first *eight* would be a second answer to "how much of a
312    /// secret is safe to print", and the wrong half: API keys are structured
313    /// at the front, so `sk-ant-a` names the issuer and, on a short token, is
314    /// a meaningful fraction of the value.
315    #[test]
316    fn redact_hides_short_keys_entirely() {
317        assert_eq!(redact(""), "****");
318        assert_eq!(redact("abc"), "****");
319        assert_eq!(redact("12345678"), "****");
320    }
321
322    #[test]
323    fn redact_shows_a_recognisable_suffix_of_a_long_key() {
324        assert_eq!(redact("sk-ant-api-key-12345"), "****2345");
325        assert_eq!(redact("123456789"), "****6789");
326        // The issuer prefix must not survive.
327        assert!(!redact("sk-ant-api-key-12345").contains("sk-ant"));
328    }
329
330    #[test]
331    fn redact_counts_characters_not_bytes() {
332        // Issue #115: a byte-based cut lands inside a multi-byte character and
333        // panics.
334        assert_eq!(redact("日本語日本語日本語"), "****語日本語");
335        // 3 characters but 9 bytes - a byte-length guard would call this "long"
336        // and print the whole key.
337        assert_eq!(redact("日本語"), "****");
338        assert_eq!(redact("日本語日本語日本"), "****");
339    }
340}