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