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