Skip to main content

leviath_cli/commands/setup/
verify.rs

1//! Proving a provider credential actually works, before the config is written.
2//!
3//! A `key.starts_with("sk-ant-")` check never touches the network, so ending
4//! `lev setup` with "All API keys look valid." on that basis is a sentence
5//! that is false for a revoked key, a key pasted with a trailing space, a key
6//! for the wrong account, and every key belonging to a provider the check does
7//! not cover at all (Google, OpenRouter, Ollama). The first time the user
8//! learns otherwise is a failed agent run.
9//!
10//! Every provider already implements
11//! [`list_models`](leviath_providers::Provider::list_models) against a real
12//! endpoint - `/v1/models` on Anthropic and OpenAI, `/v1beta/models` on Gemini,
13//! `/api/v1/models` on OpenRouter, `/api/tags` on Ollama - so one call both
14//! proves the credential and returns the model list the wizard's default-model
15//! picker needs. Two answers for the price of one round trip.
16//!
17//! ## The seam
18//!
19//! [`ProviderVerifier`] exists so no test ever reaches the network. Tests use a
20//! canned implementation, `--no-verify` uses [`SkipVerifier`], and the binary
21//! wires in [`LiveVerifier`]. A failed check is always a warning and never a
22//! blocker: an offline laptop, a corporate proxy, or a provider outage must not
23//! stop someone finishing setup.
24
25use leviath_runtime::provider_creds::ProviderCreds;
26
27/// What a verification attempt found out.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Outcome {
30    /// Not attempted - `--no-verify`, or no credential to check.
31    Skipped,
32    /// The provider answered. Carries its model ids, for the model picker.
33    Reachable {
34        /// The model ids it advertised, which is what the model picker offers.
35        models: Vec<String>,
36    },
37    /// The provider refused or could not be reached.
38    Failed {
39        /// What went wrong, shown next to the provider's row.
40        message: String,
41    },
42}
43
44impl Outcome {
45    /// A short status line for the provider card.
46    pub fn summary(&self) -> String {
47        match self {
48            Self::Skipped => "not checked".to_string(),
49            Self::Reachable { models } if models.len() == 1 => "1 model".to_string(),
50            Self::Reachable { models } => format!("{} models", models.len()),
51            Self::Failed { message } => message.clone(),
52        }
53    }
54
55    /// Model ids to offer in the default-model picker.
56    pub fn models(&self) -> &[String] {
57        match self {
58            Self::Reachable { models } => models,
59            Self::Skipped | Self::Failed { .. } => &[],
60        }
61    }
62
63    /// Whether this outcome should be drawn as a problem.
64    pub fn is_failure(&self) -> bool {
65        matches!(self, Self::Failed { .. })
66    }
67}
68
69/// Checks whether a set of provider credentials actually works.
70///
71/// The whole point is that the wizard never calls a provider directly, so its
72/// tests never open a socket.
73pub trait ProviderVerifier {
74    /// Ask the provider whether these credentials work, and what models they
75    /// reach. Never fails: an unreachable provider is an [`Outcome::Failed`],
76    /// not an error, because the wizard reports it rather than stopping.
77    ///
78    /// Returns `impl Future` rather than being an `async fn` so the `Send`
79    /// bound is part of the contract. An `async fn` in a trait leaves it
80    /// unstated, which is fine while every caller is concrete and becomes a
81    /// silent constraint the moment one is not.
82    fn verify(&self, creds: &ProviderCreds) -> impl std::future::Future<Output = Outcome> + Send;
83}
84
85/// `--no-verify`: report everything as unchecked without a round trip.
86pub struct SkipVerifier;
87
88impl ProviderVerifier for SkipVerifier {
89    async fn verify(&self, _creds: &ProviderCreds) -> Outcome {
90        Outcome::Skipped
91    }
92}
93
94/// Build a one-provider registry and ask it to list its models.
95///
96/// Split out of [`LiveVerifier`] so the mapping from "registry answer" to
97/// [`Outcome`] is exercised without a network call: a registry built from
98/// credentials for a provider name nothing recognises is empty, which drives
99/// the `None` arm, and every other arm is the provider's own I/O.
100pub async fn verify_via_registry(creds: &ProviderCreds) -> Outcome {
101    verify_via_registry_with(creds, &leviath_providers::provider::build_http_client).await
102}
103
104/// [`verify_via_registry`], with client construction injected so the
105/// "no usable HTTPS client" outcome is reachable from a test.
106pub async fn verify_via_registry_with(
107    creds: &ProviderCreds,
108    build_client: leviath_providers::provider::HttpClientFactory<'_>,
109) -> Outcome {
110    // A registry that cannot be built is exactly the failure this command
111    // exists to report, so it is an outcome rather than a panic.
112    let registry = match leviath_runtime::provider_creds::build_provider_registry_with(
113        std::slice::from_ref(creds),
114        build_client,
115    ) {
116        Ok(registry) => registry,
117        Err(e) => {
118            return Outcome::Failed {
119                message: e.to_string(),
120            };
121        }
122    };
123    let Some(provider) = registry.get(&creds.name) else {
124        return Outcome::Failed {
125            message: format!("no provider named '{}'", creds.name),
126        };
127    };
128    match provider.list_models().await {
129        Ok(models) => Outcome::Reachable {
130            models: models.into_iter().map(|m| m.id).collect(),
131        },
132        Err(e) => Outcome::Failed {
133            message: describe(&e.to_string()),
134        },
135    }
136}
137
138/// Turn a provider error into something a person can act on.
139///
140/// The raw strings are HTTP-shaped (`API error 401: {"type":"error",...}`) and
141/// the status code is the only part that tells the user what to *do*.
142fn describe(raw: &str) -> String {
143    if raw.contains("401") || raw.contains("Unauthorized") || raw.contains("invalid_api_key") {
144        "rejected - check the key".to_string()
145    } else if raw.contains("403") {
146        "forbidden - the key is valid but lacks access".to_string()
147    } else if raw.contains("429") {
148        "rate limited - the key works".to_string()
149    } else if raw.contains("timed out") || raw.contains("timeout") {
150        "timed out - no answer from the provider".to_string()
151    } else if raw.contains("dns") || raw.contains("connect") || raw.contains("Connection") {
152        "unreachable - check your network".to_string()
153    } else {
154        raw.to_string()
155    }
156}
157
158/// Production [`ProviderVerifier`]: really calls the provider.
159///
160/// Wired in only by the binary. Nothing in the library instantiates it, so no
161/// test can accidentally reach the network through it.
162pub struct LiveVerifier;
163
164impl ProviderVerifier for LiveVerifier {
165    async fn verify(&self, creds: &ProviderCreds) -> Outcome {
166        verify_via_registry(creds).await
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use leviath_testkit::spawn_mock_server;
174
175    fn creds(name: &str) -> ProviderCreds {
176        ProviderCreds {
177            name: name.to_string(),
178            api_key: Some("sk-test".to_string()),
179            base_url: None,
180            model_capabilities: std::collections::HashMap::new(),
181            request_timeout_secs: Some(1),
182            rate_limit: None,
183            options: std::collections::HashMap::new(),
184        }
185    }
186
187    // ─── Outcome ────────────────────────────────────────────────────────────
188
189    #[test]
190    fn summary_reads_naturally_for_every_outcome() {
191        assert_eq!(Outcome::Skipped.summary(), "not checked");
192        assert_eq!(
193            Outcome::Reachable {
194                models: vec!["a".into()]
195            }
196            .summary(),
197            "1 model"
198        );
199        assert_eq!(
200            Outcome::Reachable {
201                models: vec!["a".into(), "b".into()]
202            }
203            .summary(),
204            "2 models"
205        );
206        assert_eq!(
207            Outcome::Reachable { models: vec![] }.summary(),
208            "0 models",
209            "a provider that answers with nothing is still reachable"
210        );
211        assert_eq!(
212            Outcome::Failed {
213                message: "rejected - check the key".into()
214            }
215            .summary(),
216            "rejected - check the key"
217        );
218    }
219
220    #[test]
221    fn only_a_reachable_outcome_offers_models() {
222        assert_eq!(
223            Outcome::Reachable {
224                models: vec!["m".into()]
225            }
226            .models(),
227            ["m"]
228        );
229        assert!(Outcome::Skipped.models().is_empty());
230        assert!(
231            Outcome::Failed {
232                message: "x".into()
233            }
234            .models()
235            .is_empty()
236        );
237    }
238
239    #[test]
240    fn only_a_failed_outcome_reads_as_a_problem() {
241        assert!(
242            Outcome::Failed {
243                message: "x".into()
244            }
245            .is_failure()
246        );
247        assert!(!Outcome::Skipped.is_failure());
248        assert!(!Outcome::Reachable { models: vec![] }.is_failure());
249    }
250
251    // ─── describe ───────────────────────────────────────────────────────────
252
253    #[test]
254    fn describe_turns_status_codes_into_advice() {
255        assert_eq!(
256            describe("API error 401: bad key"),
257            "rejected - check the key"
258        );
259        assert_eq!(describe("Unauthorized"), "rejected - check the key");
260        assert_eq!(describe("invalid_api_key"), "rejected - check the key");
261        assert_eq!(
262            describe("API error 403: no access"),
263            "forbidden - the key is valid but lacks access"
264        );
265        // A 429 proves the credential works, which is the useful part.
266        assert_eq!(
267            describe("API error 429: slow down"),
268            "rate limited - the key works"
269        );
270        assert_eq!(
271            describe("operation timed out"),
272            "timed out - no answer from the provider"
273        );
274        assert_eq!(
275            describe("error trying to connect"),
276            "unreachable - check your network"
277        );
278        assert_eq!(describe("dns error"), "unreachable - check your network");
279        assert_eq!(
280            describe("Connection refused"),
281            "unreachable - check your network"
282        );
283    }
284
285    #[test]
286    fn describe_passes_through_anything_it_does_not_recognise() {
287        // Better a raw provider message than a wrong guess about what it means.
288        assert_eq!(describe("something entirely new"), "something entirely new");
289    }
290
291    // ─── verifiers ──────────────────────────────────────────────────────────
292
293    #[tokio::test]
294    async fn skip_verifier_never_reports_anything_but_skipped() {
295        assert_eq!(
296            SkipVerifier.verify(&creds("anthropic")).await,
297            Outcome::Skipped
298        );
299        assert_eq!(
300            SkipVerifier.verify(&creds("ollama")).await,
301            Outcome::Skipped
302        );
303    }
304
305    #[tokio::test]
306    async fn an_unknown_provider_name_fails_without_touching_the_network() {
307        // `build_provider_registry` silently ignores names it doesn't know, so
308        // the registry comes back empty. Reporting that as "unreachable" would
309        // send the user hunting for a network problem that isn't there.
310        let outcome = verify_via_registry(&creds("not-a-real-provider")).await;
311
312        assert_eq!(
313            outcome,
314            Outcome::Failed {
315                message: "no provider named 'not-a-real-provider'".to_string()
316            }
317        );
318    }
319
320    #[tokio::test]
321    async fn a_reachable_provider_reports_the_models_it_lists() {
322        // The whole reason verification calls `list_models` rather than some
323        // cheaper ping: one round trip both proves the credential and fills the
324        // wizard's default-model picker.
325        let url = spawn_mock_server(
326            200,
327            "OK",
328            r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#,
329        )
330        .await;
331        let mut creds = creds("ollama");
332        creds.api_key = None;
333        creds.base_url = Some(url);
334
335        let outcome = verify_via_registry(&creds).await;
336
337        assert_eq!(
338            outcome,
339            Outcome::Reachable {
340                models: vec!["llama3:8b".to_string(), "qwen2:7b".to_string()]
341            }
342        );
343        assert!(!outcome.is_failure());
344        assert_eq!(outcome.summary(), "2 models");
345    }
346
347    #[tokio::test]
348    async fn a_rejected_credential_is_reported_as_such_not_as_a_network_problem() {
349        // A 401 from a real endpoint is the case this whole module exists for:
350        // a prefix-only check calls this key valid.
351        let url = spawn_mock_server(401, "Unauthorized", r#"{"error":"bad key"}"#).await;
352        let mut creds = creds("ollama");
353        creds.api_key = None;
354        creds.base_url = Some(url);
355
356        let outcome = verify_via_registry(&creds).await;
357
358        assert_eq!(
359            outcome,
360            Outcome::Failed {
361                message: "rejected - check the key".to_string()
362            }
363        );
364    }
365
366    #[tokio::test]
367    async fn a_provider_pointed_at_a_dead_endpoint_fails_rather_than_hanging() {
368        // Ollama needs no key and honours `base_url`, so it can be aimed at a
369        // reserved TEST-NET-1 address (RFC 5737) that cannot route anywhere.
370        // With a 1s timeout this is bounded, and it exercises the real
371        // registry -> list_models -> error mapping path end to end.
372        let mut creds = creds("ollama");
373        creds.api_key = None;
374        creds.base_url = Some("http://192.0.2.1:11434".to_string());
375
376        let outcome = verify_via_registry(&creds).await;
377
378        assert!(outcome.is_failure(), "expected a failure, got {outcome:?}");
379        assert!(!outcome.summary().is_empty());
380        assert!(outcome.models().is_empty());
381    }
382
383    #[tokio::test]
384    async fn live_verifier_delegates_to_the_registry_path() {
385        // Same unknown-provider input, so this asserts the delegation without
386        // opening a socket.
387        let outcome = LiveVerifier.verify(&creds("not-a-real-provider")).await;
388
389        assert_eq!(
390            outcome,
391            Outcome::Failed {
392                message: "no provider named 'not-a-real-provider'".to_string()
393            }
394        );
395    }
396
397    #[tokio::test]
398    async fn a_machine_with_no_usable_https_client_reports_a_failed_outcome() {
399        // Needs a key: a keyed provider with none is skipped before any client
400        // is built, which would test the wrong branch.
401        let mut creds = leviath_runtime::provider_creds::ProviderCreds::simple("anthropic");
402        creds.api_key = Some("k".to_string());
403        let outcome = super::verify_via_registry_with(&creds, &|_t| {
404            Err(leviath_providers::provider::malformed_url_error())
405        })
406        .await;
407        // Asserted through `Debug` rather than a `let ... else`: the else arm
408        // is unreachable, and an unreachable arm is an uncovered region under
409        // the 100% gate.
410        let rendered = format!("{outcome:?}");
411        assert!(rendered.starts_with("Failed"), "{rendered}");
412        assert!(rendered.contains("root certificate store"), "{rendered}");
413    }
414}