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