use leviath_runtime::provider_creds::ProviderCreds;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Skipped,
Reachable {
models: Vec<String>,
},
Failed {
message: String,
},
}
impl Outcome {
pub fn summary(&self) -> String {
match self {
Self::Skipped => "not checked".to_string(),
Self::Reachable { models } if models.len() == 1 => "1 model".to_string(),
Self::Reachable { models } => format!("{} models", models.len()),
Self::Failed { message } => message.clone(),
}
}
pub fn models(&self) -> &[String] {
match self {
Self::Reachable { models } => models,
Self::Skipped | Self::Failed { .. } => &[],
}
}
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed { .. })
}
}
pub trait ProviderVerifier {
fn verify(&self, creds: &ProviderCreds) -> impl std::future::Future<Output = Outcome> + Send;
}
pub struct SkipVerifier;
impl ProviderVerifier for SkipVerifier {
async fn verify(&self, _creds: &ProviderCreds) -> Outcome {
Outcome::Skipped
}
}
pub async fn verify_via_registry(creds: &ProviderCreds) -> Outcome {
verify_via_registry_with(creds, &leviath_providers::provider::build_http_client).await
}
pub async fn verify_via_registry_with(
creds: &ProviderCreds,
build_client: leviath_providers::provider::HttpClientFactory<'_>,
) -> Outcome {
let registry = match leviath_runtime::provider_creds::build_provider_registry_with(
std::slice::from_ref(creds),
build_client,
) {
Ok(registry) => registry,
Err(e) => {
return Outcome::Failed {
message: e.to_string(),
};
}
};
let Some(provider) = registry.get(&creds.name) else {
return Outcome::Failed {
message: format!("no provider named '{}'", creds.name),
};
};
match provider.list_models().await {
Ok(models) => Outcome::Reachable {
models: models.into_iter().map(|m| m.id).collect(),
},
Err(e) => Outcome::Failed {
message: describe(&e.to_string()),
},
}
}
fn describe(raw: &str) -> String {
if raw.contains("401") || raw.contains("Unauthorized") || raw.contains("invalid_api_key") {
"rejected - check the key".to_string()
} else if raw.contains("403") {
"forbidden - the key is valid but lacks access".to_string()
} else if raw.contains("429") {
"rate limited - the key works".to_string()
} else if raw.contains("timed out") || raw.contains("timeout") {
"timed out - no answer from the provider".to_string()
} else if raw.contains("dns") || raw.contains("connect") || raw.contains("Connection") {
"unreachable - check your network".to_string()
} else {
raw.to_string()
}
}
pub struct LiveVerifier;
impl ProviderVerifier for LiveVerifier {
async fn verify(&self, creds: &ProviderCreds) -> Outcome {
verify_via_registry(creds).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use leviath_testkit::spawn_mock_server;
fn creds(name: &str) -> ProviderCreds {
ProviderCreds {
name: name.to_string(),
api_key: Some("sk-test".to_string()),
base_url: None,
model_capabilities: std::collections::HashMap::new(),
request_timeout_secs: Some(1),
rate_limit: None,
options: std::collections::HashMap::new(),
}
}
#[test]
fn summary_reads_naturally_for_every_outcome() {
assert_eq!(Outcome::Skipped.summary(), "not checked");
assert_eq!(
Outcome::Reachable {
models: vec!["a".into()]
}
.summary(),
"1 model"
);
assert_eq!(
Outcome::Reachable {
models: vec!["a".into(), "b".into()]
}
.summary(),
"2 models"
);
assert_eq!(
Outcome::Reachable { models: vec![] }.summary(),
"0 models",
"a provider that answers with nothing is still reachable"
);
assert_eq!(
Outcome::Failed {
message: "rejected - check the key".into()
}
.summary(),
"rejected - check the key"
);
}
#[test]
fn only_a_reachable_outcome_offers_models() {
assert_eq!(
Outcome::Reachable {
models: vec!["m".into()]
}
.models(),
["m"]
);
assert!(Outcome::Skipped.models().is_empty());
assert!(
Outcome::Failed {
message: "x".into()
}
.models()
.is_empty()
);
}
#[test]
fn only_a_failed_outcome_reads_as_a_problem() {
assert!(
Outcome::Failed {
message: "x".into()
}
.is_failure()
);
assert!(!Outcome::Skipped.is_failure());
assert!(!Outcome::Reachable { models: vec![] }.is_failure());
}
#[test]
fn describe_turns_status_codes_into_advice() {
assert_eq!(
describe("API error 401: bad key"),
"rejected - check the key"
);
assert_eq!(describe("Unauthorized"), "rejected - check the key");
assert_eq!(describe("invalid_api_key"), "rejected - check the key");
assert_eq!(
describe("API error 403: no access"),
"forbidden - the key is valid but lacks access"
);
assert_eq!(
describe("API error 429: slow down"),
"rate limited - the key works"
);
assert_eq!(
describe("operation timed out"),
"timed out - no answer from the provider"
);
assert_eq!(
describe("error trying to connect"),
"unreachable - check your network"
);
assert_eq!(describe("dns error"), "unreachable - check your network");
assert_eq!(
describe("Connection refused"),
"unreachable - check your network"
);
}
#[test]
fn describe_passes_through_anything_it_does_not_recognise() {
assert_eq!(describe("something entirely new"), "something entirely new");
}
#[tokio::test]
async fn skip_verifier_never_reports_anything_but_skipped() {
assert_eq!(
SkipVerifier.verify(&creds("anthropic")).await,
Outcome::Skipped
);
assert_eq!(
SkipVerifier.verify(&creds("ollama")).await,
Outcome::Skipped
);
}
#[tokio::test]
async fn an_unknown_provider_name_fails_without_touching_the_network() {
let outcome = verify_via_registry(&creds("not-a-real-provider")).await;
assert_eq!(
outcome,
Outcome::Failed {
message: "no provider named 'not-a-real-provider'".to_string()
}
);
}
#[tokio::test]
async fn a_reachable_provider_reports_the_models_it_lists() {
let url = spawn_mock_server(
200,
"OK",
r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#,
)
.await;
let mut creds = creds("ollama");
creds.api_key = None;
creds.base_url = Some(url);
let outcome = verify_via_registry(&creds).await;
assert_eq!(
outcome,
Outcome::Reachable {
models: vec!["llama3:8b".to_string(), "qwen2:7b".to_string()]
}
);
assert!(!outcome.is_failure());
assert_eq!(outcome.summary(), "2 models");
}
#[tokio::test]
async fn a_rejected_credential_is_reported_as_such_not_as_a_network_problem() {
let url = spawn_mock_server(401, "Unauthorized", r#"{"error":"bad key"}"#).await;
let mut creds = creds("ollama");
creds.api_key = None;
creds.base_url = Some(url);
let outcome = verify_via_registry(&creds).await;
assert_eq!(
outcome,
Outcome::Failed {
message: "rejected - check the key".to_string()
}
);
}
#[tokio::test]
async fn a_provider_pointed_at_a_dead_endpoint_fails_rather_than_hanging() {
let mut creds = creds("ollama");
creds.api_key = None;
creds.base_url = Some("http://192.0.2.1:11434".to_string());
let outcome = verify_via_registry(&creds).await;
assert!(outcome.is_failure(), "expected a failure, got {outcome:?}");
assert!(!outcome.summary().is_empty());
assert!(outcome.models().is_empty());
}
#[tokio::test]
async fn live_verifier_delegates_to_the_registry_path() {
let outcome = LiveVerifier.verify(&creds("not-a-real-provider")).await;
assert_eq!(
outcome,
Outcome::Failed {
message: "no provider named 'not-a-real-provider'".to_string()
}
);
}
#[tokio::test]
async fn a_machine_with_no_usable_https_client_reports_a_failed_outcome() {
let mut creds = leviath_runtime::provider_creds::ProviderCreds::simple("anthropic");
creds.api_key = Some("k".to_string());
let outcome = super::verify_via_registry_with(&creds, &|_t| {
Err(leviath_providers::provider::malformed_url_error())
})
.await;
let rendered = format!("{outcome:?}");
assert!(rendered.starts_with("Failed"), "{rendered}");
assert!(rendered.contains("root certificate store"), "{rendered}");
}
}