mecha10_models/services/model_service/
mod.rs1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum ModelOrigin {
80 Catalog,
82 Registry,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct InstalledModel {
89 pub name: String,
91 pub path: PathBuf,
93 pub size: u64,
95 pub catalog_entry: Option<ModelCatalogEntry>,
97 pub source: ModelOrigin,
99 pub version: Option<i32>,
102}
103
104pub struct ModelService {
106 catalog: Vec<ModelCatalogEntry>,
108 models_dir: PathBuf,
110}
111
112impl ModelService {
113 #[allow(dead_code)]
115 pub fn new() -> Result<Self> {
116 Self::with_models_dir(PathBuf::from("models"))
117 }
118
119 pub fn with_models_dir(models_dir: PathBuf) -> Result<Self> {
121 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 pub fn list_catalog(&self) -> Result<Vec<ModelCatalogEntry>> {
133 Ok(self.catalog.clone())
134 }
135
136 pub fn get_catalog_entry(&self, name: &str) -> Option<&ModelCatalogEntry> {
138 self.catalog.iter().find(|m| m.name == name)
139 }
140
141 pub(super) fn catalog_model_dir(&self, name: &str) -> PathBuf {
144 self.models_dir.join("catalog").join(name)
145 }
146
147 fn registry_root_dir(&self) -> PathBuf {
150 self.models_dir.join("registry")
151 }
152
153 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 if !path.is_dir() {
179 continue;
180 }
181
182 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 let name = path
193 .file_name()
194 .and_then(|s| s.to_str())
195 .unwrap_or("unknown")
196 .to_string();
197
198 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(®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).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 #[allow(dead_code)]
348 pub async fn validate(&self, path: &Path) -> Result<bool> {
349 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?;
361
362 Ok(bytes.len() > 4)
365 }
366}
367
368#[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 #[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 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}