use std::fmt;
use std::path::Path;
use std::time::Duration;
use serde::Deserialize;
use super::client::{truncate, CONNECT_TIMEOUT};
use super::config::ExploreConfig;
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum HealthError {
Unreachable {
url: String,
detail: String,
},
ModelMissing {
model: String,
url: String,
available: Vec<String>,
},
}
impl fmt::Display for HealthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HealthError::Unreachable { url, detail } => write!(
f,
"inference server unreachable at {url}: {detail} \
— is the server running? check `base_url` in .grove/explore.json"
),
HealthError::ModelMissing { model, url, available } => write!(
f,
"model `{model}` is not served by {url} (available: {}) \
— pull/load it, or fix `model` in .grove/explore.json",
if available.is_empty() { "none reported".to_string() } else { available.join(", ") }
),
}
}
}
impl std::error::Error for HealthError {}
#[derive(Deserialize)]
struct ModelsResponse {
#[serde(default)]
data: Vec<ModelEntry>,
}
#[derive(Deserialize)]
struct ModelEntry {
#[serde(default)]
id: String,
}
pub fn health_probe(cfg: &ExploreConfig) -> Result<(), HealthError> {
let base = cfg.base_url.trim_end_matches('/');
let url = format!("{base}/models");
let agent = ureq::AgentBuilder::new()
.timeout_connect(CONNECT_TIMEOUT)
.timeout(PROBE_TIMEOUT)
.build();
let resp = agent.get(&url).call().map_err(|e| match e {
ureq::Error::Status(status, r) => HealthError::Unreachable {
url: url.clone(),
detail: format!("HTTP {status}: {}", truncate(&r.into_string().unwrap_or_default())),
},
ureq::Error::Transport(t) => {
HealthError::Unreachable { url: url.clone(), detail: t.to_string() }
}
})?;
let raw = resp
.into_string()
.map_err(|e| HealthError::Unreachable { url: url.clone(), detail: e.to_string() })?;
let listing: ModelsResponse = serde_json::from_str(&raw).map_err(|e| {
HealthError::Unreachable {
url: url.clone(),
detail: format!("unparseable /models response: {e}"),
}
})?;
let available: Vec<String> = listing.data.into_iter().map(|m| m.id).collect();
if model_available(&cfg.model, &available) {
Ok(())
} else {
Err(HealthError::ModelMissing { model: cfg.model.clone(), url, available })
}
}
pub fn list_models(cfg: &ExploreConfig) -> Result<Vec<String>, String> {
fetch_models_at(&cfg.base_url, CONNECT_TIMEOUT, PROBE_TIMEOUT)
}
pub(crate) fn fetch_models_at(
base_url: &str,
connect: Duration,
overall: Duration,
) -> Result<Vec<String>, String> {
let base = base_url.trim_end_matches('/');
let url = format!("{base}/models");
let agent = ureq::AgentBuilder::new()
.timeout_connect(connect)
.timeout(overall)
.build();
let resp = agent.get(&url).call().map_err(|e| e.to_string())?;
let raw = resp.into_string().map_err(|e| e.to_string())?;
let listing: ModelsResponse =
serde_json::from_str(&raw).map_err(|e| format!("unparseable /models response: {e}"))?;
Ok(listing
.data
.into_iter()
.map(|m| m.id)
.filter(|id| !id.is_empty())
.collect())
}
fn model_available(want: &str, have: &[String]) -> bool {
if have.is_empty() {
return true;
}
let want_base = want.split(':').next().unwrap_or(want);
have.iter().any(|id| {
if id == want || id.contains(want) {
return true;
}
let stem = Path::new(id)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(id);
stem == want || (!want_base.is_empty() && stem.contains(want_base))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn unreachable_config() -> ExploreConfig {
ExploreConfig {
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "test-model".to_string(),
..ExploreConfig::default()
}
}
#[test]
fn health_probe_against_unreachable_url_is_unreachable() {
let err = health_probe(&unreachable_config())
.expect_err("a closed port must not pass the health probe");
match err {
HealthError::Unreachable { url, .. } => {
assert!(url.contains("127.0.0.1:1"), "message names the endpoint: {url}");
assert!(url.ends_with("/models"));
}
other => panic!("expected Unreachable, got {other:?}"),
}
}
#[test]
fn model_matching_is_tolerant() {
assert!(model_available("qwen2.5-coder:7b", &["qwen2.5-coder:7b".into()]));
assert!(model_available(
"qwen2.5-coder:7b",
&["/models/qwen2.5-coder-7b-instruct.gguf".into()]
));
assert!(model_available("anything", &[]));
assert!(!model_available("llama3", &["qwen2.5-coder:7b".into()]));
}
}