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        // Load embedded model catalog
122        let catalog_toml = include_str!("../../../model_catalog.toml");
123        let catalog: ModelCatalog = toml::from_str(catalog_toml).context("Failed to parse model_catalog.toml")?;
124
125        Ok(Self {
126            catalog: catalog.models,
127            models_dir,
128        })
129    }
130
131    /// List all models in the catalog
132    pub fn list_catalog(&self) -> Result<Vec<ModelCatalogEntry>> {
133        Ok(self.catalog.clone())
134    }
135
136    /// Get catalog entry for a model by name
137    pub fn get_catalog_entry(&self, name: &str) -> Option<&ModelCatalogEntry> {
138        self.catalog.iter().find(|m| m.name == name)
139    }
140
141    /// `models_dir/catalog/<name>/` - the public catalog's namespaced
142    /// subdirectory. See the module docs above (`LAB-1802`).
143    pub(super) fn catalog_model_dir(&self, name: &str) -> PathBuf {
144        self.models_dir.join("catalog").join(name)
145    }
146
147    /// `models_dir/registry/` - the root under which every registry model's
148    /// per-version subdirectories live. See the module docs above (`LAB-1802`).
149    fn registry_root_dir(&self) -> PathBuf {
150        self.models_dir.join("registry")
151    }
152
153    /// List all installed models, from both the catalog and registry namespaced
154    /// subtrees (`LAB-1802`) - this is what makes a registry model (e.g. one
155    /// auto-registered from a completed training job and then locally fetched via
156    /// `mecha10 model set`) show up in `mecha10 model list`/`installed`
157    /// alongside catalog models, instead of only being visible through the
158    /// separate `mecha10 model` registry verb.
159    pub async fn list_installed(&self) -> Result<Vec<InstalledModel>> {
160        let mut installed = self.list_installed_catalog().await?;
161        installed.extend(self.list_installed_registry().await?);
162        Ok(installed)
163    }
164
165    async fn list_installed_catalog(&self) -> Result<Vec<InstalledModel>> {
166        let catalog_dir = self.models_dir.join("catalog");
167        if !catalog_dir.exists() {
168            return Ok(Vec::new());
169        }
170
171        let mut installed = Vec::new();
172        let mut entries = fs::read_dir(&catalog_dir).await?;
173
174        while let Some(entry) = entries.next_entry().await? {
175            let path = entry.path();
176
177            // Only look at directories (each model has its own directory)
178            if !path.is_dir() {
179                continue;
180            }
181
182            // Check if model.onnx exists in this directory
183            let model_path = path.join("model.onnx");
184            if !model_path.exists() {
185                continue;
186            }
187
188            let metadata = fs::metadata(&model_path).await?;
189            let size = metadata.len();
190
191            // Extract model name from directory name
192            let name = path
193                .file_name()
194                .and_then(|s| s.to_str())
195                .unwrap_or("unknown")
196                .to_string();
197
198            // Try to find catalog entry
199            let catalog_entry = self.get_catalog_entry(&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).cloned();
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        // Basic validation: check file exists and has .onnx extension
350        if !path.exists() {
351            return Ok(false);
352        }
353
354        if path.extension().and_then(|s| s.to_str()) != Some("onnx") {
355            return Ok(false);
356        }
357
358        // TODO: Add ONNX format validation using ort crate
359        // For now, just check the magic bytes
360        let bytes = fs::read(path).await?;
361
362        // ONNX files are Protocol Buffers, check for protobuf signature
363        // This is a very basic check - full validation would require parsing
364        Ok(bytes.len() > 4)
365    }
366}
367
368/// Combined model information (catalog + installed)
369#[derive(Debug, Clone, Serialize)]
370pub struct ModelInfo {
371    pub name: String,
372    pub catalog_entry: Option<ModelCatalogEntry>,
373    pub installed_info: Option<InstalledModel>,
374}
375
376impl ModelInfo {
377    /// Check if model is installed
378    #[allow(dead_code)]
379    pub fn is_installed(&self) -> bool {
380        self.installed_info.is_some()
381    }
382}
383
384#[cfg(test)]
385mod info_tests {
386    use super::*;
387
388    fn service(models_dir: PathBuf) -> ModelService {
389        ModelService::with_models_dir(models_dir).unwrap()
390    }
391
392    async fn write_model(dir: PathBuf) {
393        fs::create_dir_all(&dir).await.unwrap();
394        fs::write(dir.join("model.onnx"), b"fake-onnx-bytes").await.unwrap();
395    }
396
397    #[tokio::test]
398    async fn info_finds_a_registry_origin_model_not_just_catalog() {
399        // LAB-1808: `info` must work for a Registry-origin model too, not just
400        // Catalog - `models info` shouldn't require the model to also exist in
401        // the (versionless) public catalog.
402        let tmp = tempfile::tempdir().unwrap();
403        let models_dir = tmp.path().to_path_buf();
404        write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
405
406        let service = service(models_dir);
407        let info = service.info("rover-detector", None).await.unwrap();
408
409        assert!(info.catalog_entry.is_none());
410        let installed = info.installed_info.expect("registry model should be found");
411        assert_eq!(installed.source, ModelOrigin::Registry);
412        assert_eq!(installed.version, Some(3));
413    }
414
415    #[tokio::test]
416    async fn info_with_no_version_reports_the_highest_installed_registry_version() {
417        let tmp = tempfile::tempdir().unwrap();
418        let models_dir = tmp.path().to_path_buf();
419        write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
420        write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
421        write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
422
423        let service = service(models_dir);
424        let info = service.info("rover-detector", None).await.unwrap();
425
426        assert_eq!(info.installed_info.unwrap().version, Some(3));
427    }
428
429    #[tokio::test]
430    async fn info_with_an_explicit_version_reports_that_version() {
431        let tmp = tempfile::tempdir().unwrap();
432        let models_dir = tmp.path().to_path_buf();
433        write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
434        write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
435
436        let service = service(models_dir);
437        let info = service.info("rover-detector", Some(1)).await.unwrap();
438
439        assert_eq!(info.installed_info.unwrap().version, Some(1));
440    }
441
442    #[tokio::test]
443    async fn info_with_an_unknown_explicit_version_reports_not_installed() {
444        let tmp = tempfile::tempdir().unwrap();
445        let models_dir = tmp.path().to_path_buf();
446        write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
447
448        let service = service(models_dir);
449        let info = service.info("rover-detector", Some(99)).await.unwrap();
450
451        assert!(info.installed_info.is_none());
452    }
453
454    #[tokio::test]
455    async fn info_for_a_catalog_model_ignores_version_and_reports_it() {
456        let tmp = tempfile::tempdir().unwrap();
457        let models_dir = tmp.path().to_path_buf();
458        write_model(models_dir.join("catalog").join("yolov8n")).await;
459
460        let service = service(models_dir);
461        let info = service.info("yolov8n", None).await.unwrap();
462
463        let installed = info.installed_info.expect("catalog model should be found");
464        assert_eq!(installed.source, ModelOrigin::Catalog);
465        assert_eq!(installed.version, None);
466    }
467
468    #[tokio::test]
469    async fn info_for_a_completely_unknown_model_reports_not_installed_and_no_catalog_entry() {
470        let tmp = tempfile::tempdir().unwrap();
471        let service = service(tmp.path().to_path_buf());
472
473        let info = service.info("nonexistent-model", None).await.unwrap();
474
475        assert!(info.catalog_entry.is_none());
476        assert!(info.installed_info.is_none());
477    }
478}