Skip to main content

mecha10_models/services/model_service/
mod.rs

1//! Model management service for downloading and caching AI models from HuggingFace Hub
2//!
3//! This service provides a unified interface for:
4//! - Downloading models from HuggingFace Hub using the model catalog
5//! - Caching models to the project's models/ directory
6//! - Listing available and installed models
7//! - Managing model lifecycle (pull, remove, info)
8//!
9//! # Design
10//!
11//! - The public catalog is served live by `mecha10-api` (`GET /api/models/catalog`) - see
12//!   [`catalog::fetch_catalog`]. No bundled/compiled-in copy and no fallback; this requires
13//!   network access to `mecha10-api`.
14//! - Models are downloaded from HuggingFace Hub on first use (lazy loading)
15//! - Models are cached to `<project_root>/models/<name>.onnx`
16//! - HF cache is also used (`~/.cache/huggingface/hub/`) for offline support
17//!
18//! # Usage
19//!
20//! ```ignore
21//! use mecha10_models::services::ModelService;
22//!
23//! let service = ModelService::new()?;
24//!
25//! // List catalog models
26//! let catalog = service.list_catalog().await?;
27//!
28//! // Download a model
29//! service.pull("yolov8n", None).await?;
30//!
31//! // List installed models
32//! let installed = service.list_installed()?;
33//! ```
34//!
35//! # Module layout
36//!
37//! - [`catalog`]: catalog/HuggingFace config schema types (re-exported)
38//! - [`download`]: downloading the `.onnx` model artifact
39//! - [`embed_metadata`]: embedding resolved labels into the model's own ONNX
40//!   `metadata_props` (object-detection models)
41//! - [`labels`]: downloading/generating the `labels.txt` file
42//! - [`preprocessing`]: preprocessing auto-detection and `config.json` generation
43//! - [`quantize`]: INT8 quantization
44//! - [`registry`]: downloading a versioned artifact from the private per-project
45//!   model registry (`LAB-1802`)
46//!
47//! # Storage layout (`LAB-1802`)
48//!
49//! Catalog and registry artifacts used to both land at the same
50//! `<models_dir>/<name>/model.onnx` path, so a registry model sharing a name with
51//! a catalog entry (or vice versa) would silently overwrite the other's file. Each
52//! source now gets its own namespaced subtree so a name collision can never
53//! clobber the wrong artifact:
54//!
55//! - Catalog: `<models_dir>/catalog/<name>/model.onnx` (versionless - see
56//!   [`ModelService::catalog_model_dir`])
57//! - Registry: `<models_dir>/registry/<name>/<version>/model.onnx` (see
58//!   [`ModelService::registry_model_dir`])
59
60mod catalog;
61#[cfg(feature = "model-download")]
62mod download;
63#[cfg(feature = "model-download")]
64mod embed_metadata;
65#[cfg(feature = "model-download")]
66mod labels;
67mod preprocessing;
68#[cfg(feature = "model-download")]
69mod quantize;
70mod registry;
71
72pub use catalog::{ModelCatalogEntry, PreprocessingPreset, QuantizeConfig};
73
74use anyhow::{Context, Result};
75use serde::{Deserialize, Serialize};
76use std::path::{Path, PathBuf};
77use tokio::fs;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum ModelOrigin {
82    Catalog,
83    Registry,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct InstalledModel {
88    pub name: String,
89    pub path: PathBuf,
90    pub size: u64,
91    pub catalog_entry: Option<ModelCatalogEntry>,
92    pub source: ModelOrigin,
93    pub version: Option<i32>,
94}
95
96pub struct ModelService {
97    models_dir: PathBuf,
98}
99
100impl ModelService {
101    /// Create a new ModelService with default settings
102    #[allow(dead_code)]
103    pub fn new() -> Result<Self> {
104        Self::with_models_dir(PathBuf::from("models"))
105    }
106
107    /// Create a ModelService with custom models directory. Infallible (always `Ok`) - kept
108    /// returning `Result` for call-site compatibility with when this used to also parse the
109    /// compiled-in `model_catalog.toml`, which could fail.
110    pub fn with_models_dir(models_dir: PathBuf) -> Result<Self> {
111        Ok(Self { models_dir })
112    }
113
114    /// List all models in the public catalog - a live `GET /api/models/catalog` call every
115    /// time (see [`catalog::fetch_catalog`]), no caching, no fallback.
116    pub async fn list_catalog(&self) -> Result<Vec<ModelCatalogEntry>> {
117        catalog::fetch_catalog().await
118    }
119
120    /// Get catalog entry for a model by name - fetches the live catalog (see [`Self::list_catalog`])
121    /// and looks it up by name.
122    pub async fn get_catalog_entry(&self, name: &str) -> Result<Option<ModelCatalogEntry>> {
123        let catalog = self.list_catalog().await?;
124        Ok(catalog.into_iter().find(|m| m.name == name))
125    }
126
127    /// `models_dir/catalog/<name>/` - the public catalog's namespaced
128    /// subdirectory. See the module docs above (`LAB-1802`).
129    pub(super) fn catalog_model_dir(&self, name: &str) -> PathBuf {
130        self.models_dir.join("catalog").join(name)
131    }
132
133    /// Find a Python 3 executable. Shared by `quantize` (INT8 conversion) and
134    /// `embed_metadata` (embedding resolved `class_names` into a model's ONNX
135    /// `metadata_props`) - both shell out to an embedded Python script since
136    /// neither operation has a suitable pure-Rust crate already in the workspace
137    /// (the workspace's `ort` dependency is inference-only, with no model-writing
138    /// support).
139    #[cfg(feature = "model-download")]
140    pub(super) fn find_python(&self) -> Result<String> {
141        for candidate in &["python3", "python"] {
142            if which::which(candidate).is_ok() {
143                return Ok(candidate.to_string());
144            }
145        }
146        anyhow::bail!("Python 3 not found. Install with: brew install python3 (macOS) or apt install python3 (Linux)")
147    }
148
149    /// `models_dir/registry/` - the root under which every registry model's
150    /// per-version subdirectories live. See the module docs above (`LAB-1802`).
151    fn registry_root_dir(&self) -> PathBuf {
152        self.models_dir.join("registry")
153    }
154
155    /// List all installed models, from both the catalog and registry namespaced
156    /// subtrees (`LAB-1802`) - this is what makes a registry model (e.g. one
157    /// auto-registered from a completed training job and then locally fetched via
158    /// `mecha10 model set`) show up in `mecha10 model list`/`installed`
159    /// alongside catalog models, instead of only being visible through the
160    /// separate `mecha10 model` registry verb.
161    pub async fn list_installed(&self) -> Result<Vec<InstalledModel>> {
162        let mut installed = self.list_installed_catalog().await?;
163        installed.extend(self.list_installed_registry().await?);
164        Ok(installed)
165    }
166
167    async fn list_installed_catalog(&self) -> Result<Vec<InstalledModel>> {
168        let catalog_dir = self.models_dir.join("catalog");
169        if !catalog_dir.exists() {
170            return Ok(Vec::new());
171        }
172
173        let catalog = self.list_catalog().await.unwrap_or_default();
174
175        let mut installed = Vec::new();
176        let mut entries = fs::read_dir(&catalog_dir).await?;
177
178        while let Some(entry) = entries.next_entry().await? {
179            let path = entry.path();
180
181            if !path.is_dir() {
182                continue;
183            }
184
185            let model_path = path.join("model.onnx");
186            if !model_path.exists() {
187                continue;
188            }
189
190            let metadata = fs::metadata(&model_path).await?;
191            let size = metadata.len();
192
193            let name = path
194                .file_name()
195                .and_then(|s| s.to_str())
196                .unwrap_or("unknown")
197                .to_string();
198
199            let catalog_entry = catalog.iter().find(|m| m.name == name).cloned();
200
201            installed.push(InstalledModel {
202                name,
203                path: model_path,
204                size,
205                catalog_entry,
206                source: ModelOrigin::Catalog,
207                version: None,
208            });
209        }
210
211        Ok(installed)
212    }
213
214    async fn list_installed_registry(&self) -> Result<Vec<InstalledModel>> {
215        let registry_dir = self.registry_root_dir();
216        if !registry_dir.exists() {
217            return Ok(Vec::new());
218        }
219
220        let mut installed = Vec::new();
221        let mut model_entries = fs::read_dir(&registry_dir).await?;
222
223        while let Some(model_entry) = model_entries.next_entry().await? {
224            let model_dir = model_entry.path();
225            if !model_dir.is_dir() {
226                continue;
227            }
228            let name = model_dir
229                .file_name()
230                .and_then(|s| s.to_str())
231                .unwrap_or("unknown")
232                .to_string();
233
234            let mut version_entries = fs::read_dir(&model_dir).await?;
235            while let Some(version_entry) = version_entries.next_entry().await? {
236                let version_dir = version_entry.path();
237                if !version_dir.is_dir() {
238                    continue;
239                }
240
241                let model_path = version_dir.join("model.onnx");
242                if !model_path.exists() {
243                    continue;
244                }
245
246                let metadata = fs::metadata(&model_path).await?;
247                let version = version_dir
248                    .file_name()
249                    .and_then(|s| s.to_str())
250                    .and_then(|s| s.parse().ok());
251
252                installed.push(InstalledModel {
253                    name: name.clone(),
254                    path: model_path,
255                    size: metadata.len(),
256                    catalog_entry: None,
257                    source: ModelOrigin::Registry,
258                    version,
259                });
260            }
261        }
262
263        Ok(installed)
264    }
265
266    /// Remove an installed catalog model. Registry models are versioned (a name
267    /// alone doesn't identify a unique artifact - see the module docs), so this
268    /// only ever removes from the catalog namespace; if `name` only exists under
269    /// the registry namespace, this returns a clear error pointing at the
270    /// versioned directory instead of silently doing nothing or guessing a
271    /// version to delete (`LAB-1802`).
272    pub async fn remove(&self, name: &str) -> Result<()> {
273        let catalog_dir = self.catalog_model_dir(name);
274
275        if catalog_dir.exists() {
276            fs::remove_dir_all(&catalog_dir)
277                .await
278                .context(format!("Failed to remove model '{}'", name))?;
279            return Ok(());
280        }
281
282        let registry_model_dir = self.registry_root_dir().join(name);
283        if registry_model_dir.exists() {
284            anyhow::bail!(
285                "Model '{}' is a registry model (versioned) - remove a specific version's directory under '{}' directly",
286                name,
287                registry_model_dir.display()
288            );
289        }
290
291        anyhow::bail!("Model '{}' is not installed", name);
292    }
293
294    /// Get the path to a catalog model's ONNX file
295    ///
296    /// Note: This method is no longer used by CLI (replaced by node-runner in Phase 2).
297    /// Kept for potential future use.
298    #[allow(dead_code)]
299    pub fn get_model_path(&self, name: &str) -> PathBuf {
300        self.catalog_model_dir(name).join("model.onnx")
301    }
302
303    /// Check if a catalog model is installed
304    ///
305    /// Note: This method is no longer used by CLI (replaced by node-runner in Phase 2).
306    /// Kept for potential future use.
307    #[allow(dead_code)]
308    pub async fn is_installed(&self, name: &str) -> bool {
309        let model_path = self.get_model_path(name);
310        model_path.exists()
311    }
312
313    /// Get info about a model (catalog or installed), from either origin
314    /// (`LAB-1808`) - a Registry-origin model is just as valid a target here as
315    /// a Catalog one, since `list_installed` already merges both namespaced
316    /// subtrees.
317    ///
318    /// `version` disambiguates which installed entry to report when a
319    /// Registry model has more than one version cached locally (a Catalog
320    /// entry is versionless/always-latest, so `version` is ignored for it -
321    /// there is only ever one candidate). When `version` is `None`, the
322    /// highest installed Registry version is reported (Catalog has no
323    /// versions to pick between).
324    pub async fn info(&self, name: &str, version: Option<i32>) -> Result<ModelInfo> {
325        let catalog_entry = self.get_catalog_entry(name).await.unwrap_or(None);
326        let installed = self.list_installed().await?;
327
328        let installed_info = match version {
329            Some(requested) => installed
330                .into_iter()
331                .find(|m| m.name == name && m.version == Some(requested)),
332            None => {
333                let mut candidates: Vec<InstalledModel> = installed.into_iter().filter(|m| m.name == name).collect();
334                candidates.sort_by_key(|m| m.version.unwrap_or(0));
335                candidates.pop()
336            }
337        };
338
339        Ok(ModelInfo {
340            name: name.to_string(),
341            catalog_entry,
342            installed_info,
343        })
344    }
345
346    /// Validate a model file is a valid ONNX file
347    #[allow(dead_code)]
348    pub async fn validate(&self, path: &Path) -> Result<bool> {
349        if !path.exists() {
350            return Ok(false);
351        }
352
353        if path.extension().and_then(|s| s.to_str()) != Some("onnx") {
354            return Ok(false);
355        }
356
357        let bytes = fs::read(path).await?;
358
359        Ok(bytes.len() > 4)
360    }
361}
362
363#[derive(Debug, Clone, Serialize)]
364pub struct ModelInfo {
365    pub name: String,
366    pub catalog_entry: Option<ModelCatalogEntry>,
367    pub installed_info: Option<InstalledModel>,
368}
369
370impl ModelInfo {
371    /// Check if model is installed
372    #[allow(dead_code)]
373    pub fn is_installed(&self) -> bool {
374        self.installed_info.is_some()
375    }
376}
377
378#[cfg(test)]
379mod info_tests {
380    use super::*;
381
382    fn service(models_dir: PathBuf) -> ModelService {
383        ModelService::with_models_dir(models_dir).unwrap()
384    }
385
386    async fn write_model(dir: PathBuf) {
387        fs::create_dir_all(&dir).await.unwrap();
388        fs::write(dir.join("model.onnx"), b"fake-onnx-bytes").await.unwrap();
389    }
390
391    #[tokio::test]
392    async fn info_finds_a_registry_origin_model_not_just_catalog() {
393        let tmp = tempfile::tempdir().unwrap();
394        let models_dir = tmp.path().to_path_buf();
395        write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
396
397        let service = service(models_dir);
398        let info = service.info("rover-detector", None).await.unwrap();
399
400        assert!(info.catalog_entry.is_none());
401        let installed = info.installed_info.expect("registry model should be found");
402        assert_eq!(installed.source, ModelOrigin::Registry);
403        assert_eq!(installed.version, Some(3));
404    }
405
406    #[tokio::test]
407    async fn info_with_no_version_reports_the_highest_installed_registry_version() {
408        let tmp = tempfile::tempdir().unwrap();
409        let models_dir = tmp.path().to_path_buf();
410        write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
411        write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
412        write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
413
414        let service = service(models_dir);
415        let info = service.info("rover-detector", None).await.unwrap();
416
417        assert_eq!(info.installed_info.unwrap().version, Some(3));
418    }
419
420    #[tokio::test]
421    async fn info_with_an_explicit_version_reports_that_version() {
422        let tmp = tempfile::tempdir().unwrap();
423        let models_dir = tmp.path().to_path_buf();
424        write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
425        write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
426
427        let service = service(models_dir);
428        let info = service.info("rover-detector", Some(1)).await.unwrap();
429
430        assert_eq!(info.installed_info.unwrap().version, Some(1));
431    }
432
433    #[tokio::test]
434    async fn info_with_an_unknown_explicit_version_reports_not_installed() {
435        let tmp = tempfile::tempdir().unwrap();
436        let models_dir = tmp.path().to_path_buf();
437        write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
438
439        let service = service(models_dir);
440        let info = service.info("rover-detector", Some(99)).await.unwrap();
441
442        assert!(info.installed_info.is_none());
443    }
444
445    #[tokio::test]
446    async fn info_for_a_catalog_model_ignores_version_and_reports_it() {
447        let tmp = tempfile::tempdir().unwrap();
448        let models_dir = tmp.path().to_path_buf();
449        write_model(models_dir.join("catalog").join("yolov8n")).await;
450
451        let service = service(models_dir);
452        let info = service.info("yolov8n", None).await.unwrap();
453
454        let installed = info.installed_info.expect("catalog model should be found");
455        assert_eq!(installed.source, ModelOrigin::Catalog);
456        assert_eq!(installed.version, None);
457    }
458
459    #[tokio::test]
460    async fn info_for_a_completely_unknown_model_reports_not_installed_and_no_catalog_entry() {
461        let tmp = tempfile::tempdir().unwrap();
462        let service = service(tmp.path().to_path_buf());
463
464        let info = service.info("nonexistent-model", None).await.unwrap();
465
466        assert!(info.catalog_entry.is_none());
467        assert!(info.installed_info.is_none());
468    }
469}