mod catalog;
#[cfg(feature = "model-download")]
mod download;
#[cfg(feature = "model-download")]
mod embed_metadata;
#[cfg(feature = "model-download")]
mod labels;
mod preprocessing;
#[cfg(feature = "model-download")]
mod quantize;
mod registry;
pub use catalog::{ModelCatalogEntry, PreprocessingPreset, QuantizeConfig};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelOrigin {
Catalog,
Registry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledModel {
pub name: String,
pub path: PathBuf,
pub size: u64,
pub catalog_entry: Option<ModelCatalogEntry>,
pub source: ModelOrigin,
pub version: Option<i32>,
}
pub struct ModelService {
models_dir: PathBuf,
}
impl ModelService {
#[allow(dead_code)]
pub fn new() -> Result<Self> {
Self::with_models_dir(PathBuf::from("models"))
}
pub fn with_models_dir(models_dir: PathBuf) -> Result<Self> {
Ok(Self { models_dir })
}
pub async fn list_catalog(&self) -> Result<Vec<ModelCatalogEntry>> {
catalog::fetch_catalog().await
}
pub async fn get_catalog_entry(&self, name: &str) -> Result<Option<ModelCatalogEntry>> {
let catalog = self.list_catalog().await?;
Ok(catalog.into_iter().find(|m| m.name == name))
}
pub(super) fn catalog_model_dir(&self, name: &str) -> PathBuf {
self.models_dir.join("catalog").join(name)
}
#[cfg(feature = "model-download")]
pub(super) fn find_python(&self) -> Result<String> {
for candidate in &["python3", "python"] {
if which::which(candidate).is_ok() {
return Ok(candidate.to_string());
}
}
anyhow::bail!("Python 3 not found. Install with: brew install python3 (macOS) or apt install python3 (Linux)")
}
fn registry_root_dir(&self) -> PathBuf {
self.models_dir.join("registry")
}
pub async fn list_installed(&self) -> Result<Vec<InstalledModel>> {
let mut installed = self.list_installed_catalog().await?;
installed.extend(self.list_installed_registry().await?);
Ok(installed)
}
async fn list_installed_catalog(&self) -> Result<Vec<InstalledModel>> {
let catalog_dir = self.models_dir.join("catalog");
if !catalog_dir.exists() {
return Ok(Vec::new());
}
let catalog = self.list_catalog().await.unwrap_or_default();
let mut installed = Vec::new();
let mut entries = fs::read_dir(&catalog_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path.is_dir() {
continue;
}
let model_path = path.join("model.onnx");
if !model_path.exists() {
continue;
}
let metadata = fs::metadata(&model_path).await?;
let size = metadata.len();
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let catalog_entry = catalog.iter().find(|m| m.name == name).cloned();
installed.push(InstalledModel {
name,
path: model_path,
size,
catalog_entry,
source: ModelOrigin::Catalog,
version: None,
});
}
Ok(installed)
}
async fn list_installed_registry(&self) -> Result<Vec<InstalledModel>> {
let registry_dir = self.registry_root_dir();
if !registry_dir.exists() {
return Ok(Vec::new());
}
let mut installed = Vec::new();
let mut model_entries = fs::read_dir(®istry_dir).await?;
while let Some(model_entry) = model_entries.next_entry().await? {
let model_dir = model_entry.path();
if !model_dir.is_dir() {
continue;
}
let name = model_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let mut version_entries = fs::read_dir(&model_dir).await?;
while let Some(version_entry) = version_entries.next_entry().await? {
let version_dir = version_entry.path();
if !version_dir.is_dir() {
continue;
}
let model_path = version_dir.join("model.onnx");
if !model_path.exists() {
continue;
}
let metadata = fs::metadata(&model_path).await?;
let version = version_dir
.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.parse().ok());
installed.push(InstalledModel {
name: name.clone(),
path: model_path,
size: metadata.len(),
catalog_entry: None,
source: ModelOrigin::Registry,
version,
});
}
}
Ok(installed)
}
pub async fn remove(&self, name: &str) -> Result<()> {
let catalog_dir = self.catalog_model_dir(name);
if catalog_dir.exists() {
fs::remove_dir_all(&catalog_dir)
.await
.context(format!("Failed to remove model '{}'", name))?;
return Ok(());
}
let registry_model_dir = self.registry_root_dir().join(name);
if registry_model_dir.exists() {
anyhow::bail!(
"Model '{}' is a registry model (versioned) - remove a specific version's directory under '{}' directly",
name,
registry_model_dir.display()
);
}
anyhow::bail!("Model '{}' is not installed", name);
}
#[allow(dead_code)]
pub fn get_model_path(&self, name: &str) -> PathBuf {
self.catalog_model_dir(name).join("model.onnx")
}
#[allow(dead_code)]
pub async fn is_installed(&self, name: &str) -> bool {
let model_path = self.get_model_path(name);
model_path.exists()
}
pub async fn info(&self, name: &str, version: Option<i32>) -> Result<ModelInfo> {
let catalog_entry = self.get_catalog_entry(name).await.unwrap_or(None);
let installed = self.list_installed().await?;
let installed_info = match version {
Some(requested) => installed
.into_iter()
.find(|m| m.name == name && m.version == Some(requested)),
None => {
let mut candidates: Vec<InstalledModel> = installed.into_iter().filter(|m| m.name == name).collect();
candidates.sort_by_key(|m| m.version.unwrap_or(0));
candidates.pop()
}
};
Ok(ModelInfo {
name: name.to_string(),
catalog_entry,
installed_info,
})
}
#[allow(dead_code)]
pub async fn validate(&self, path: &Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
if path.extension().and_then(|s| s.to_str()) != Some("onnx") {
return Ok(false);
}
let bytes = fs::read(path).await?;
Ok(bytes.len() > 4)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ModelInfo {
pub name: String,
pub catalog_entry: Option<ModelCatalogEntry>,
pub installed_info: Option<InstalledModel>,
}
impl ModelInfo {
#[allow(dead_code)]
pub fn is_installed(&self) -> bool {
self.installed_info.is_some()
}
}
#[cfg(test)]
mod info_tests {
use super::*;
fn service(models_dir: PathBuf) -> ModelService {
ModelService::with_models_dir(models_dir).unwrap()
}
async fn write_model(dir: PathBuf) {
fs::create_dir_all(&dir).await.unwrap();
fs::write(dir.join("model.onnx"), b"fake-onnx-bytes").await.unwrap();
}
#[tokio::test]
async fn info_finds_a_registry_origin_model_not_just_catalog() {
let tmp = tempfile::tempdir().unwrap();
let models_dir = tmp.path().to_path_buf();
write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
let service = service(models_dir);
let info = service.info("rover-detector", None).await.unwrap();
assert!(info.catalog_entry.is_none());
let installed = info.installed_info.expect("registry model should be found");
assert_eq!(installed.source, ModelOrigin::Registry);
assert_eq!(installed.version, Some(3));
}
#[tokio::test]
async fn info_with_no_version_reports_the_highest_installed_registry_version() {
let tmp = tempfile::tempdir().unwrap();
let models_dir = tmp.path().to_path_buf();
write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
let service = service(models_dir);
let info = service.info("rover-detector", None).await.unwrap();
assert_eq!(info.installed_info.unwrap().version, Some(3));
}
#[tokio::test]
async fn info_with_an_explicit_version_reports_that_version() {
let tmp = tempfile::tempdir().unwrap();
let models_dir = tmp.path().to_path_buf();
write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
let service = service(models_dir);
let info = service.info("rover-detector", Some(1)).await.unwrap();
assert_eq!(info.installed_info.unwrap().version, Some(1));
}
#[tokio::test]
async fn info_with_an_unknown_explicit_version_reports_not_installed() {
let tmp = tempfile::tempdir().unwrap();
let models_dir = tmp.path().to_path_buf();
write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
let service = service(models_dir);
let info = service.info("rover-detector", Some(99)).await.unwrap();
assert!(info.installed_info.is_none());
}
#[tokio::test]
async fn info_for_a_catalog_model_ignores_version_and_reports_it() {
let tmp = tempfile::tempdir().unwrap();
let models_dir = tmp.path().to_path_buf();
write_model(models_dir.join("catalog").join("yolov8n")).await;
let service = service(models_dir);
let info = service.info("yolov8n", None).await.unwrap();
let installed = info.installed_info.expect("catalog model should be found");
assert_eq!(installed.source, ModelOrigin::Catalog);
assert_eq!(installed.version, None);
}
#[tokio::test]
async fn info_for_a_completely_unknown_model_reports_not_installed_and_no_catalog_entry() {
let tmp = tempfile::tempdir().unwrap();
let service = service(tmp.path().to_path_buf());
let info = service.info("nonexistent-model", None).await.unwrap();
assert!(info.catalog_entry.is_none());
assert!(info.installed_info.is_none());
}
}