use std::path::Path;
use std::time::Duration;
use harn_vm::llm::api::OllamaPsModel;
use harn_vm::llm::readiness::{probe_provider_readiness, ProviderReadiness, ReadinessStatus};
use harn_vm::llm_config::{self, ProviderDef};
use serde::Serialize;
use super::state::{read_pid_record, PidRecord};
pub(crate) const LOCAL_PROVIDERS: &[&str] = &["ollama", "llamacpp", "mlx", "local", "vllm"];
#[derive(Debug, Clone, Serialize)]
pub(crate) struct LocalProviderSnapshot {
pub provider: String,
pub display_name: Option<String>,
pub base_url: String,
pub base_url_env: Option<String>,
pub port: Option<u16>,
pub reachable: bool,
pub readiness_status: String,
pub message: String,
pub served_models: Vec<String>,
pub loaded_models: Vec<LoadedModel>,
pub pid_record: Option<PidRecord>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct LoadedModel {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_vram_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_length: Option<u64>,
}
pub(crate) fn local_provider_ids(filter: Option<&str>) -> Vec<String> {
let mut ids = Vec::new();
if let Some(name) = filter.map(str::trim).filter(|name| !name.is_empty()) {
ids.push(name.to_string());
return ids;
}
for id in LOCAL_PROVIDERS {
if llm_config::provider_config(id).is_some() {
ids.push((*id).to_string());
}
}
ids
}
pub(crate) async fn snapshot_provider(
provider: &str,
state_dir: &Path,
) -> Result<LocalProviderSnapshot, String> {
let def = llm_config::provider_config(provider)
.ok_or_else(|| format!("unknown provider: {provider}"))?;
let base_url = llm_config::resolve_base_url(&def);
let (reachable, status, message, served_models) = if provider == "ollama" {
snapshot_ollama_reachability(&base_url).await
} else {
snapshot_openai_reachability(provider, &base_url).await
};
let loaded_models = if provider == "ollama" && reachable {
fetch_ollama_ps(&base_url).await.unwrap_or_default()
} else {
Vec::new()
};
Ok(LocalProviderSnapshot {
provider: provider.to_string(),
display_name: def.display_name.clone(),
base_url: base_url.clone(),
base_url_env: def.base_url_env.clone(),
port: port_from_base_url(&base_url),
reachable,
readiness_status: status,
message,
served_models,
loaded_models,
pid_record: read_pid_record(state_dir, provider).ok().flatten(),
})
}
async fn snapshot_openai_reachability(
provider: &str,
base_url: &str,
) -> (bool, String, String, Vec<String>) {
let readiness = probe_provider_readiness(provider, None, Some(base_url)).await;
let ProviderReadiness {
ok,
status,
message,
served_models,
..
} = readiness;
(ok, readiness_status_label(status), message, served_models)
}
async fn snapshot_ollama_reachability(base_url: &str) -> (bool, String, String, Vec<String>) {
match fetch_ollama_tags(base_url).await {
Ok(served) => {
let message = format!("Ollama is reachable at {base_url}; {} served", served.len());
(true, "ok".to_string(), message, served)
}
Err(OllamaProbeError { status, message }) => (false, status, message, Vec::new()),
}
}
#[derive(Debug)]
struct OllamaProbeError {
status: String,
message: String,
}
async fn fetch_ollama_tags(base_url: &str) -> Result<Vec<String>, OllamaProbeError> {
let url = ollama_endpoint(base_url, "/api/tags").map_err(|message| OllamaProbeError {
status: "invalid_url".to_string(),
message,
})?;
let client = local_http_client().map_err(|message| OllamaProbeError {
status: "client_error".to_string(),
message,
})?;
let response = client
.get(url.clone())
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(4))
.send()
.await
.map_err(|error| OllamaProbeError {
status: "unreachable".to_string(),
message: format!("Ollama daemon not reachable at {url}: {error}"),
})?;
if !response.status().is_success() {
return Err(OllamaProbeError {
status: "bad_status".to_string(),
message: format!("Ollama /api/tags returned HTTP {}", response.status()),
});
}
let body: serde_json::Value = response.json().await.map_err(|error| OllamaProbeError {
status: "bad_response".to_string(),
message: format!("Ollama /api/tags returned unparsable body: {error}"),
})?;
Ok(body
.get("models")
.and_then(|value| value.as_array())
.map(|entries| {
entries
.iter()
.filter_map(|entry| {
entry
.get("name")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.collect::<Vec<_>>()
})
.unwrap_or_default())
}
fn ollama_endpoint(base_url: &str, path: &str) -> Result<reqwest::Url, String> {
let mut url = reqwest::Url::parse(base_url)
.map_err(|error| format!("invalid Ollama base URL '{base_url}': {error}"))?;
if url.host_str() == Some("localhost") {
url.set_host(Some("127.0.0.1"))
.map_err(|_| format!("invalid Ollama base URL '{base_url}'"))?;
}
url.set_path(path);
Ok(url)
}
fn readiness_status_label(status: ReadinessStatus) -> String {
match status {
ReadinessStatus::Ok => "ok",
ReadinessStatus::UnknownProvider => "unknown_provider",
ReadinessStatus::InvalidUrl => "invalid_url",
ReadinessStatus::Unreachable => "unreachable",
ReadinessStatus::BadStatus => "bad_status",
ReadinessStatus::BadResponse => "bad_response",
ReadinessStatus::ModelMissing => "model_missing",
}
.to_string()
}
fn port_from_base_url(base_url: &str) -> Option<u16> {
reqwest::Url::parse(base_url)
.ok()
.and_then(|url| url.port())
}
pub(crate) async fn fetch_ollama_ps(base_url: &str) -> Result<Vec<LoadedModel>, String> {
let url = ollama_endpoint(base_url, "/api/ps")?;
let client = local_http_client()?;
let response = client
.get(url)
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(4))
.send()
.await
.map_err(|error| error.to_string())?;
if !response.status().is_success() {
return Err(format!("/api/ps returned HTTP {}", response.status()));
}
let body: serde_json::Value = response.json().await.map_err(|error| error.to_string())?;
let models = body.get("models").and_then(|value| value.as_array());
let Some(models) = models else {
return Ok(Vec::new());
};
Ok(models
.iter()
.filter_map(|entry| {
let ps = OllamaPsModel::from_ps_entry(entry)?;
let name = ps.name?;
Some(LoadedModel {
name,
size_bytes: ps.size_bytes,
size_vram_bytes: ps.size_vram_bytes,
expires_at: ps.expires_at,
context_length: ps.context_length,
})
})
.collect())
}
pub(crate) async fn ollama_unload_model(base_url: &str, model: &str) -> Result<(), String> {
let url = ollama_endpoint(base_url, "/api/generate")?;
let body = serde_json::json!({
"model": model,
"prompt": "",
"stream": false,
"keep_alive": 0,
});
let client = local_http_client()?;
let response = client
.post(url)
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(8))
.json(&body)
.send()
.await
.map_err(|error| format!("Ollama unload failed: {error}"))?;
if response.status().is_success() {
Ok(())
} else {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
Err(format!(
"Ollama unload returned HTTP {}: {}",
status.as_u16(),
detail
))
}
}
#[cfg(unix)]
pub(crate) fn terminate_pid(pid: u32) -> Result<(), String> {
use std::convert::TryFrom;
let raw = i32::try_from(pid).map_err(|_| format!("pid {pid} is out of range"))?;
let rc = unsafe { libc::kill(raw, libc::SIGTERM) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error().to_string())
}
}
#[cfg(not(unix))]
pub(crate) fn terminate_pid(pid: u32) -> Result<(), String> {
Err(format!(
"terminating pid {pid} is not supported on this platform"
))
}
pub(crate) fn resolve_provider_def(provider: &str) -> Result<ProviderDef, String> {
llm_config::provider_config(provider)
.ok_or_else(|| format!("unknown provider '{provider}' in Harn provider catalog"))
}
fn local_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(2))
.timeout(Duration::from_secs(10))
.build()
.map_err(|error| format!("failed to build HTTP client: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_provider_ids_filters_unknown_filter() {
let ids = local_provider_ids(Some("ollama"));
assert_eq!(ids, vec!["ollama".to_string()]);
}
#[test]
fn local_provider_ids_returns_canonical_list() {
let ids = local_provider_ids(None);
assert!(ids.contains(&"ollama".to_string()));
assert!(ids.contains(&"llamacpp".to_string()));
assert!(ids.contains(&"mlx".to_string()));
}
#[test]
fn port_from_base_url_recognizes_explicit_port() {
assert_eq!(port_from_base_url("http://127.0.0.1:11434"), Some(11434));
assert_eq!(port_from_base_url("http://localhost:8001/v1"), Some(8001));
assert_eq!(port_from_base_url("https://api.example.com"), None);
}
}