leviath_cli/commands/setup/
verify.rs1use leviath_runtime::provider_creds::ProviderCreds;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Outcome {
30 Skipped,
32 Reachable {
34 models: Vec<String>,
36 },
37 Failed {
39 message: String,
41 },
42}
43
44impl Outcome {
45 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 pub fn models(&self) -> &[String] {
57 match self {
58 Self::Reachable { models } => models,
59 Self::Skipped | Self::Failed { .. } => &[],
60 }
61 }
62
63 pub fn is_failure(&self) -> bool {
65 matches!(self, Self::Failed { .. })
66 }
67}
68
69pub trait ProviderVerifier {
74 fn verify(&self, creds: &ProviderCreds) -> impl std::future::Future<Output = Outcome> + Send;
83}
84
85pub struct SkipVerifier;
87
88impl ProviderVerifier for SkipVerifier {
89 async fn verify(&self, _creds: &ProviderCreds) -> Outcome {
90 Outcome::Skipped
91 }
92}
93
94pub async fn verify_via_registry(creds: &ProviderCreds) -> Outcome {
101 verify_via_registry_with(creds, &leviath_providers::provider::build_http_client).await
102}
103
104pub async fn verify_via_registry_with(
107 creds: &ProviderCreds,
108 build_client: leviath_providers::provider::HttpClientFactory<'_>,
109) -> Outcome {
110 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
138fn 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
158pub 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 #[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 #[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 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 assert_eq!(describe("something entirely new"), "something entirely new");
289 }
290
291 #[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 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 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 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 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 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 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 let rendered = format!("{outcome:?}");
411 assert!(rendered.starts_with("Failed"), "{rendered}");
412 assert!(rendered.contains("root certificate store"), "{rendered}");
413 }
414}