use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive] pub enum Reachability {
Reachable,
Unreachable {
detail: String,
},
Unauthorized,
ModelNotAdvertised {
listed: usize,
},
ListingUnsupported,
}
const MODELS_PATH: &str = "/v1/models";
#[must_use]
pub fn probe_openai(
base_url: &str,
model: &str,
token: Option<&str>,
timeout: Duration,
) -> Reachability {
let url = format!("{}{MODELS_PATH}", crate::openai::base_url(base_url));
let agent =
crate::http_client::bounded_agent(crate::http_client::AgentBudget::uniform(timeout));
let mut request = agent.get(&url);
if let Some(secret) = token {
request = request.set("Authorization", &format!("Bearer {secret}"));
}
match request.call() {
Ok(response) => classify_listing(&response.into_string().unwrap_or_default(), model),
Err(ureq::Error::Status(401 | 403, _)) => Reachability::Unauthorized,
Err(ureq::Error::Status(404 | 405, _)) => Reachability::ListingUnsupported,
Err(ureq::Error::Status(code, _)) => Reachability::Unreachable {
detail: format!("the server answered HTTP {code}"),
},
Err(ureq::Error::Transport(transport)) => Reachability::Unreachable {
detail: transport.to_string(),
},
}
}
fn classify_listing(body: &str, model: &str) -> Reachability {
let ids: Vec<&str> = body
.split("\"id\"")
.skip(1)
.filter_map(|chunk| chunk.split('"').nth(1))
.collect();
if ids.is_empty() {
return Reachability::ListingUnsupported;
}
if ids.iter().any(|id| names_the_same_model(id, model)) {
Reachability::Reachable
} else {
Reachability::ModelNotAdvertised { listed: ids.len() }
}
}
fn names_the_same_model(listed: &str, configured: &str) -> bool {
listed == configured
|| listed.strip_suffix(":latest") == Some(configured)
|| configured.strip_suffix(":latest") == Some(listed)
}
fn finding(outcome: &Reachability) -> Option<(String, &'static str)> {
match outcome {
Reachability::Reachable => None,
Reachability::Unreachable { detail } => Some((
format!("unreachable ({detail})"),
"start the server, or correct the URL",
)),
Reachability::Unauthorized => Some((
"refused the credential".to_owned(),
"set the role's _API_TOKEN in the environment (never in the TOML)",
)),
Reachability::ModelNotAdvertised { listed } => Some((
format!(
"answered, but does not advertise this model alias among the \
{listed} it lists — the alias may still be routable by the server"
),
"no action if the server routes this alias; otherwise name one it lists",
)),
Reachability::ListingUnsupported => Some((
"answered, but serves no model listing — reachability unconfirmed".to_owned(),
"no action if this is a gateway; otherwise check the URL's base path",
)),
}
}
fn proves_backend_unusable(outcome: &Reachability) -> bool {
match outcome {
Reachability::Unreachable { .. } | Reachability::Unauthorized => true,
Reachability::Reachable
| Reachability::ModelNotAdvertised { .. }
| Reachability::ListingUnsupported => false,
}
}
#[must_use]
pub fn warning_line(role: &str, url: &str, model: &str, outcome: &Reachability) -> Option<String> {
let (what, action) = finding(outcome)?;
let consequence = if proves_backend_unusable(outcome) {
"Graph enrichment will degrade silently for every write until it is fixed"
} else {
"Whether graph enrichment works is therefore unconfirmed — this is not \
proof that it is broken"
};
Some(format!(
"velesdb-memory: the {role} backend at {url} (model {model}) {what}. \
{consequence} — {action}, then restart. To run without it, unset \
VELESDB_MEMORY_EXTRACTOR or turn autograph off."
))
}
#[cfg(test)]
#[path = "reachability_tests.rs"]
mod tests;