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 { models: Vec<String> },
34    /// The provider refused or could not be reached.
35    Failed { message: String },
36}
37
38impl Outcome {
39    /// A short status line for the provider card.
40    pub fn summary(&self) -> String {
41        match self {
42            Self::Skipped => "not checked".to_string(),
43            Self::Reachable { models } if models.len() == 1 => "1 model".to_string(),
44            Self::Reachable { models } => format!("{} models", models.len()),
45            Self::Failed { message } => message.clone(),
46        }
47    }
48
49    /// Model ids to offer in the default-model picker.
50    pub fn models(&self) -> &[String] {
51        match self {
52            Self::Reachable { models } => models,
53            Self::Skipped | Self::Failed { .. } => &[],
54        }
55    }
56
57    /// Whether this outcome should be drawn as a problem.
58    pub fn is_failure(&self) -> bool {
59        matches!(self, Self::Failed { .. })
60    }
61}
62
63/// Checks whether a set of provider credentials actually works.
64///
65/// The whole point is that the wizard never calls a provider directly, so its
66/// tests never open a socket.
67#[allow(async_fn_in_trait)] // callers are concrete; no `dyn` and no boxing needed
68pub trait ProviderVerifier {
69    async fn verify(&self, creds: &ProviderCreds) -> Outcome;
70}
71
72/// `--no-verify`: report everything as unchecked without a round trip.
73pub struct SkipVerifier;
74
75impl ProviderVerifier for SkipVerifier {
76    async fn verify(&self, _creds: &ProviderCreds) -> Outcome {
77        Outcome::Skipped
78    }
79}
80
81/// Build a one-provider registry and ask it to list its models.
82///
83/// Split out of [`LiveVerifier`] so the mapping from "registry answer" to
84/// [`Outcome`] is exercised without a network call: a registry built from
85/// credentials for a provider name nothing recognises is empty, which drives
86/// the `None` arm, and every other arm is the provider's own I/O.
87pub async fn verify_via_registry(creds: &ProviderCreds) -> Outcome {
88    let registry =
89        leviath_runtime::provider_creds::build_provider_registry(std::slice::from_ref(creds));
90    let Some(provider) = registry.get(&creds.name) else {
91        return Outcome::Failed {
92            message: format!("no provider named '{}'", creds.name),
93        };
94    };
95    match provider.list_models().await {
96        Ok(models) => Outcome::Reachable {
97            models: models.into_iter().map(|m| m.id).collect(),
98        },
99        Err(e) => Outcome::Failed {
100            message: describe(&e.to_string()),
101        },
102    }
103}
104
105/// Turn a provider error into something a person can act on.
106///
107/// The raw strings are HTTP-shaped (`API error 401: {"type":"error",...}`) and
108/// the status code is the only part that tells the user what to *do*.
109fn describe(raw: &str) -> String {
110    if raw.contains("401") || raw.contains("Unauthorized") || raw.contains("invalid_api_key") {
111        "rejected - check the key".to_string()
112    } else if raw.contains("403") {
113        "forbidden - the key is valid but lacks access".to_string()
114    } else if raw.contains("429") {
115        "rate limited - the key works".to_string()
116    } else if raw.contains("timed out") || raw.contains("timeout") {
117        "timed out - no answer from the provider".to_string()
118    } else if raw.contains("dns") || raw.contains("connect") || raw.contains("Connection") {
119        "unreachable - check your network".to_string()
120    } else {
121        raw.to_string()
122    }
123}
124
125/// Production [`ProviderVerifier`]: really calls the provider.
126///
127/// Wired in only by the binary. Nothing in the library instantiates it, so no
128/// test can accidentally reach the network through it.
129pub struct LiveVerifier;
130
131impl ProviderVerifier for LiveVerifier {
132    async fn verify(&self, creds: &ProviderCreds) -> Outcome {
133        verify_via_registry(creds).await
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use leviath_testkit::spawn_mock_server;
141
142    fn creds(name: &str) -> ProviderCreds {
143        ProviderCreds {
144            name: name.to_string(),
145            api_key: Some("sk-test".to_string()),
146            base_url: None,
147            model_capabilities: std::collections::HashMap::new(),
148            request_timeout_secs: Some(1),
149            rate_limit: None,
150            options: std::collections::HashMap::new(),
151        }
152    }
153
154    // ─── Outcome ────────────────────────────────────────────────────────────
155
156    #[test]
157    fn summary_reads_naturally_for_every_outcome() {
158        assert_eq!(Outcome::Skipped.summary(), "not checked");
159        assert_eq!(
160            Outcome::Reachable {
161                models: vec!["a".into()]
162            }
163            .summary(),
164            "1 model"
165        );
166        assert_eq!(
167            Outcome::Reachable {
168                models: vec!["a".into(), "b".into()]
169            }
170            .summary(),
171            "2 models"
172        );
173        assert_eq!(
174            Outcome::Reachable { models: vec![] }.summary(),
175            "0 models",
176            "a provider that answers with nothing is still reachable"
177        );
178        assert_eq!(
179            Outcome::Failed {
180                message: "rejected - check the key".into()
181            }
182            .summary(),
183            "rejected - check the key"
184        );
185    }
186
187    #[test]
188    fn only_a_reachable_outcome_offers_models() {
189        assert_eq!(
190            Outcome::Reachable {
191                models: vec!["m".into()]
192            }
193            .models(),
194            ["m"]
195        );
196        assert!(Outcome::Skipped.models().is_empty());
197        assert!(
198            Outcome::Failed {
199                message: "x".into()
200            }
201            .models()
202            .is_empty()
203        );
204    }
205
206    #[test]
207    fn only_a_failed_outcome_reads_as_a_problem() {
208        assert!(
209            Outcome::Failed {
210                message: "x".into()
211            }
212            .is_failure()
213        );
214        assert!(!Outcome::Skipped.is_failure());
215        assert!(!Outcome::Reachable { models: vec![] }.is_failure());
216    }
217
218    // ─── describe ───────────────────────────────────────────────────────────
219
220    #[test]
221    fn describe_turns_status_codes_into_advice() {
222        assert_eq!(
223            describe("API error 401: bad key"),
224            "rejected - check the key"
225        );
226        assert_eq!(describe("Unauthorized"), "rejected - check the key");
227        assert_eq!(describe("invalid_api_key"), "rejected - check the key");
228        assert_eq!(
229            describe("API error 403: no access"),
230            "forbidden - the key is valid but lacks access"
231        );
232        // A 429 proves the credential works, which is the useful part.
233        assert_eq!(
234            describe("API error 429: slow down"),
235            "rate limited - the key works"
236        );
237        assert_eq!(
238            describe("operation timed out"),
239            "timed out - no answer from the provider"
240        );
241        assert_eq!(
242            describe("error trying to connect"),
243            "unreachable - check your network"
244        );
245        assert_eq!(describe("dns error"), "unreachable - check your network");
246        assert_eq!(
247            describe("Connection refused"),
248            "unreachable - check your network"
249        );
250    }
251
252    #[test]
253    fn describe_passes_through_anything_it_does_not_recognise() {
254        // Better a raw provider message than a wrong guess about what it means.
255        assert_eq!(describe("something entirely new"), "something entirely new");
256    }
257
258    // ─── verifiers ──────────────────────────────────────────────────────────
259
260    #[tokio::test]
261    async fn skip_verifier_never_reports_anything_but_skipped() {
262        assert_eq!(
263            SkipVerifier.verify(&creds("anthropic")).await,
264            Outcome::Skipped
265        );
266        assert_eq!(
267            SkipVerifier.verify(&creds("ollama")).await,
268            Outcome::Skipped
269        );
270    }
271
272    #[tokio::test]
273    async fn an_unknown_provider_name_fails_without_touching_the_network() {
274        // `build_provider_registry` silently ignores names it doesn't know, so
275        // the registry comes back empty. Reporting that as "unreachable" would
276        // send the user hunting for a network problem that isn't there.
277        let outcome = verify_via_registry(&creds("not-a-real-provider")).await;
278
279        assert_eq!(
280            outcome,
281            Outcome::Failed {
282                message: "no provider named 'not-a-real-provider'".to_string()
283            }
284        );
285    }
286
287    #[tokio::test]
288    async fn a_reachable_provider_reports_the_models_it_lists() {
289        // The whole reason verification calls `list_models` rather than some
290        // cheaper ping: one round trip both proves the credential and fills the
291        // wizard's default-model picker.
292        let url = spawn_mock_server(
293            200,
294            "OK",
295            r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#,
296        )
297        .await;
298        let mut creds = creds("ollama");
299        creds.api_key = None;
300        creds.base_url = Some(url);
301
302        let outcome = verify_via_registry(&creds).await;
303
304        assert_eq!(
305            outcome,
306            Outcome::Reachable {
307                models: vec!["llama3:8b".to_string(), "qwen2:7b".to_string()]
308            }
309        );
310        assert!(!outcome.is_failure());
311        assert_eq!(outcome.summary(), "2 models");
312    }
313
314    #[tokio::test]
315    async fn a_rejected_credential_is_reported_as_such_not_as_a_network_problem() {
316        // A 401 from a real endpoint is the case this whole module exists for:
317        // a prefix-only check calls this key valid.
318        let url = spawn_mock_server(401, "Unauthorized", r#"{"error":"bad key"}"#).await;
319        let mut creds = creds("ollama");
320        creds.api_key = None;
321        creds.base_url = Some(url);
322
323        let outcome = verify_via_registry(&creds).await;
324
325        assert_eq!(
326            outcome,
327            Outcome::Failed {
328                message: "rejected - check the key".to_string()
329            }
330        );
331    }
332
333    #[tokio::test]
334    async fn a_provider_pointed_at_a_dead_endpoint_fails_rather_than_hanging() {
335        // Ollama needs no key and honours `base_url`, so it can be aimed at a
336        // reserved TEST-NET-1 address (RFC 5737) that cannot route anywhere.
337        // With a 1s timeout this is bounded, and it exercises the real
338        // registry -> list_models -> error mapping path end to end.
339        let mut creds = creds("ollama");
340        creds.api_key = None;
341        creds.base_url = Some("http://192.0.2.1:11434".to_string());
342
343        let outcome = verify_via_registry(&creds).await;
344
345        assert!(outcome.is_failure(), "expected a failure, got {outcome:?}");
346        assert!(!outcome.summary().is_empty());
347        assert!(outcome.models().is_empty());
348    }
349
350    #[tokio::test]
351    async fn live_verifier_delegates_to_the_registry_path() {
352        // Same unknown-provider input, so this asserts the delegation without
353        // opening a socket.
354        let outcome = LiveVerifier.verify(&creds("not-a-real-provider")).await;
355
356        assert_eq!(
357            outcome,
358            Outcome::Failed {
359                message: "no provider named 'not-a-real-provider'".to_string()
360            }
361        );
362    }
363}