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 { models: Vec<String> },
34 Failed { message: String },
36}
37
38impl Outcome {
39 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 pub fn models(&self) -> &[String] {
51 match self {
52 Self::Reachable { models } => models,
53 Self::Skipped | Self::Failed { .. } => &[],
54 }
55 }
56
57 pub fn is_failure(&self) -> bool {
59 matches!(self, Self::Failed { .. })
60 }
61}
62
63#[allow(async_fn_in_trait)] pub trait ProviderVerifier {
69 async fn verify(&self, creds: &ProviderCreds) -> Outcome;
70}
71
72pub struct SkipVerifier;
74
75impl ProviderVerifier for SkipVerifier {
76 async fn verify(&self, _creds: &ProviderCreds) -> Outcome {
77 Outcome::Skipped
78 }
79}
80
81pub 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
105fn 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
125pub 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 #[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 #[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 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 assert_eq!(describe("something entirely new"), "something entirely new");
256 }
257
258 #[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 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 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 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 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 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}