leviath_cli/commands/setup/
catalog.rs1use crate::config::Config;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Credential {
18 ApiKey,
20 BaseUrl,
22 None,
25}
26
27#[derive(Debug, Clone)]
29pub struct Provider {
30 pub id: &'static str,
33 pub display: &'static str,
35 pub blurb: &'static str,
37 pub credential: Credential,
39 pub hint: &'static str,
41 pub env_var: Option<&'static str>,
44 pub signup_url: Option<&'static str>,
46}
47
48pub 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
112pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
116
117pub const OLLAMA_MAX_CONCURRENT_INFERENCES: usize = 1;
122
123pub 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
139pub 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 _ => {}
150 }
151}
152
153pub fn is_configured(config: &Config, id: &str) -> bool {
156 match id {
157 "claude-code" => config.providers.claude_code_enabled,
158 _ => stored_credential(config, id).is_some(),
161 }
162}
163
164pub fn redact(key: &str) -> String {
177 leviath_core::redact(key)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[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 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 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 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 assert!(cc.blurb.contains("terms"));
248 assert_eq!(cc.credential, Credential::None);
249 assert!(!Config::default().providers.claude_code_enabled);
250 }
251
252 #[test]
255 fn every_provider_with_a_credential_round_trips_through_the_config() {
256 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 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 #[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 assert!(!redact("sk-ant-api-key-12345").contains("sk-ant"));
328 }
329
330 #[test]
331 fn redact_counts_characters_not_bytes() {
332 assert_eq!(redact("日本語日本語日本語"), "****語日本語");
335 assert_eq!(redact("日本語"), "****");
338 assert_eq!(redact("日本語日本語日本"), "****");
339 }
340}