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");
122 let catalog: ModelCatalog = toml::from_str(catalog_toml).context("Failed to parse model_catalog.toml")?;
123
124 Ok(Self {
125 catalog: catalog.models,
126 models_dir,
127 })
128 }
129
130 pub fn list_catalog(&self) -> Result<Vec<ModelCatalogEntry>> {
132 Ok(self.catalog.clone())
133 }
134
135 pub fn get_catalog_entry(&self, name: &str) -> Option<&ModelCatalogEntry> {
139 self.catalog.iter().find(|m| m.name == name)
140 }
141
142 pub(super) fn catalog_model_dir(&self, name: &str) -> PathBuf {
145 self.models_dir.join("catalog").join(name)
146 }
147
148 fn registry_root_dir(&self) -> PathBuf {
151 self.models_dir.join("registry")
152 }
153
154 pub async fn list_installed(&self) -> Result<Vec<InstalledModel>> {
161 let mut installed = self.list_installed_catalog().await?;
162 installed.extend(self.list_installed_registry().await?);
163 Ok(installed)
164 }
165
166 async fn list_installed_catalog(&self) -> Result<Vec<InstalledModel>> {
167 let catalog_dir = self.models_dir.join("catalog");
168 if !catalog_dir.exists() {
169 return Ok(Vec::new());
170 }
171
172 let mut installed = Vec::new();
173 let mut entries = fs::read_dir(&catalog_dir).await?;
174
175 while let Some(entry) = entries.next_entry().await? {
176 let path = entry.path();
177
178 if !path.is_dir() {
179 continue;
180 }
181
182 let model_path = path.join("model.onnx");
183 if !model_path.exists() {
184 continue;
185 }
186
187 let metadata = fs::metadata(&model_path).await?;
188 let size = metadata.len();
189
190 let name = path
191 .file_name()
192 .and_then(|s| s.to_str())
193 .unwrap_or("unknown")
194 .to_string();
195
196 let catalog_entry = self.get_catalog_entry(&name).cloned();
197
198 installed.push(InstalledModel {
199 name,
200 path: model_path,
201 size,
202 catalog_entry,
203 source: ModelOrigin::Catalog,
204 version: None,
205 });
206 }
207
208 Ok(installed)
209 }
210
211 async fn list_installed_registry(&self) -> Result<Vec<InstalledModel>> {
212 let registry_dir = self.registry_root_dir();
213 if !registry_dir.exists() {
214 return Ok(Vec::new());
215 }
216
217 let mut installed = Vec::new();
218 let mut model_entries = fs::read_dir(®istry_dir).await?;
219
220 while let Some(model_entry) = model_entries.next_entry().await? {
221 let model_dir = model_entry.path();
222 if !model_dir.is_dir() {
223 continue;
224 }
225 let name = model_dir
226 .file_name()
227 .and_then(|s| s.to_str())
228 .unwrap_or("unknown")
229 .to_string();
230
231 let mut version_entries = fs::read_dir(&model_dir).await?;
232 while let Some(version_entry) = version_entries.next_entry().await? {
233 let version_dir = version_entry.path();
234 if !version_dir.is_dir() {
235 continue;
236 }
237
238 let model_path = version_dir.join("model.onnx");
239 if !model_path.exists() {
240 continue;
241 }
242
243 let metadata = fs::metadata(&model_path).await?;
244 let version = version_dir
245 .file_name()
246 .and_then(|s| s.to_str())
247 .and_then(|s| s.parse().ok());
248
249 installed.push(InstalledModel {
250 name: name.clone(),
251 path: model_path,
252 size: metadata.len(),
253 catalog_entry: None,
254 source: ModelOrigin::Registry,
255 version,
256 });
257 }
258 }
259
260 Ok(installed)
261 }
262
263 pub async fn remove(&self, name: &str) -> Result<()> {
270 let catalog_dir = self.catalog_model_dir(name);
271
272 if catalog_dir.exists() {
273 fs::remove_dir_all(&catalog_dir)
274 .await
275 .context(format!("Failed to remove model '{}'", name))?;
276 return Ok(());
277 }
278
279 let registry_model_dir = self.registry_root_dir().join(name);
280 if registry_model_dir.exists() {
281 anyhow::bail!(
282 "Model '{}' is a registry model (versioned) - remove a specific version's directory under '{}' directly",
283 name,
284 registry_model_dir.display()
285 );
286 }
287
288 anyhow::bail!("Model '{}' is not installed", name);
289 }
290
291 #[allow(dead_code)]
296 pub fn get_model_path(&self, name: &str) -> PathBuf {
297 self.catalog_model_dir(name).join("model.onnx")
298 }
299
300 #[allow(dead_code)]
305 pub async fn is_installed(&self, name: &str) -> bool {
306 let model_path = self.get_model_path(name);
307 model_path.exists()
308 }
309
310 pub async fn info(&self, name: &str, version: Option<i32>) -> Result<ModelInfo> {
322 let catalog_entry = self.get_catalog_entry(name).cloned();
323 let installed = self.list_installed().await?;
324
325 let installed_info = match version {
326 Some(requested) => installed
327 .into_iter()
328 .find(|m| m.name == name && m.version == Some(requested)),
329 None => {
330 let mut candidates: Vec<InstalledModel> = installed.into_iter().filter(|m| m.name == name).collect();
331 candidates.sort_by_key(|m| m.version.unwrap_or(0));
332 candidates.pop()
333 }
334 };
335
336 Ok(ModelInfo {
337 name: name.to_string(),
338 catalog_entry,
339 installed_info,
340 })
341 }
342
343 #[allow(dead_code)]
345 pub async fn validate(&self, path: &Path) -> Result<bool> {
346 if !path.exists() {
347 return Ok(false);
348 }
349
350 if path.extension().and_then(|s| s.to_str()) != Some("onnx") {
351 return Ok(false);
352 }
353
354 let bytes = fs::read(path).await?;
356
357 Ok(bytes.len() > 4)
358 }
359}
360
361#[derive(Debug, Clone, Serialize)]
363pub struct ModelInfo {
364 pub name: String,
365 pub catalog_entry: Option<ModelCatalogEntry>,
366 pub installed_info: Option<InstalledModel>,
367}
368
369impl ModelInfo {
370 #[allow(dead_code)]
372 pub fn is_installed(&self) -> bool {
373 self.installed_info.is_some()
374 }
375}
376
377#[cfg(test)]
378mod info_tests {
379 use super::*;
380
381 fn service(models_dir: PathBuf) -> ModelService {
382 ModelService::with_models_dir(models_dir).unwrap()
383 }
384
385 async fn write_model(dir: PathBuf) {
386 fs::create_dir_all(&dir).await.unwrap();
387 fs::write(dir.join("model.onnx"), b"fake-onnx-bytes").await.unwrap();
388 }
389
390 #[tokio::test]
391 async fn info_finds_a_registry_origin_model_not_just_catalog() {
392 let tmp = tempfile::tempdir().unwrap();
393 let models_dir = tmp.path().to_path_buf();
394 write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
395
396 let service = service(models_dir);
397 let info = service.info("rover-detector", None).await.unwrap();
398
399 assert!(info.catalog_entry.is_none());
400 let installed = info.installed_info.expect("registry model should be found");
401 assert_eq!(installed.source, ModelOrigin::Registry);
402 assert_eq!(installed.version, Some(3));
403 }
404
405 #[tokio::test]
406 async fn info_with_no_version_reports_the_highest_installed_registry_version() {
407 let tmp = tempfile::tempdir().unwrap();
408 let models_dir = tmp.path().to_path_buf();
409 write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
410 write_model(models_dir.join("registry").join("rover-detector").join("3")).await;
411 write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
412
413 let service = service(models_dir);
414 let info = service.info("rover-detector", None).await.unwrap();
415
416 assert_eq!(info.installed_info.unwrap().version, Some(3));
417 }
418
419 #[tokio::test]
420 async fn info_with_an_explicit_version_reports_that_version() {
421 let tmp = tempfile::tempdir().unwrap();
422 let models_dir = tmp.path().to_path_buf();
423 write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
424 write_model(models_dir.join("registry").join("rover-detector").join("2")).await;
425
426 let service = service(models_dir);
427 let info = service.info("rover-detector", Some(1)).await.unwrap();
428
429 assert_eq!(info.installed_info.unwrap().version, Some(1));
430 }
431
432 #[tokio::test]
433 async fn info_with_an_unknown_explicit_version_reports_not_installed() {
434 let tmp = tempfile::tempdir().unwrap();
435 let models_dir = tmp.path().to_path_buf();
436 write_model(models_dir.join("registry").join("rover-detector").join("1")).await;
437
438 let service = service(models_dir);
439 let info = service.info("rover-detector", Some(99)).await.unwrap();
440
441 assert!(info.installed_info.is_none());
442 }
443
444 #[tokio::test]
445 async fn info_for_a_catalog_model_ignores_version_and_reports_it() {
446 let tmp = tempfile::tempdir().unwrap();
447 let models_dir = tmp.path().to_path_buf();
448 write_model(models_dir.join("catalog").join("yolov8n")).await;
449
450 let service = service(models_dir);
451 let info = service.info("yolov8n", None).await.unwrap();
452
453 let installed = info.installed_info.expect("catalog model should be found");
454 assert_eq!(installed.source, ModelOrigin::Catalog);
455 assert_eq!(installed.version, None);
456 }
457
458 #[tokio::test]
459 async fn info_for_a_completely_unknown_model_reports_not_installed_and_no_catalog_entry() {
460 let tmp = tempfile::tempdir().unwrap();
461 let service = service(tmp.path().to_path_buf());
462
463 let info = service.info("nonexistent-model", None).await.unwrap();
464
465 assert!(info.catalog_entry.is_none());
466 assert!(info.installed_info.is_none());
467 }
468}