mecha10_models/services/model_service/
mod.rs1mod 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 #[allow(dead_code)]
104 pub fn new() -> Result<Self> {
105 Self::with_models_dir(PathBuf::from("models"))
106 }
107
108 pub fn with_models_dir(models_dir: PathBuf) -> Result<Self> {
112 Ok(Self { models_dir })
113 }
114
115 pub async fn list_catalog(&self) -> Result<Vec<ModelCatalogEntry>> {
118 Ok(catalog::catalog_entries())
119 }
120
121 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 pub(super) fn catalog_model_dir(&self, name: &str) -> PathBuf {
131 self.models_dir.join("catalog").join(name)
132 }
133
134 #[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 fn registry_root_dir(&self) -> PathBuf {
153 self.models_dir.join("registry")
154 }
155
156 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(®istry_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 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 #[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 #[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 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 #[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 #[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}