1use anyhow::{Context, Result};
2pub use mesh_llm_types::models::capabilities::{CapabilityLevel, ModelCapabilities};
3use mesh_llm_types::models::capabilities::{merge_config_signals, merge_name_signals};
4use model_artifact::{ModelFormat, ResolvedModelArtifact, resolve_model_artifact_ref};
5use model_hf::HfModelRepository;
6use model_ref::{format_model_ref, normalize_gguf_distribution_id, quant_selector_from_gguf_file};
7use serde::Deserialize;
8use serde_json::Value;
9use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct InstalledModel {
14 pub model_ref: String,
15 pub path: PathBuf,
16 pub size_bytes: Option<u64>,
17 pub capabilities: ModelCapabilities,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct ModelSummary {
22 pub id: String,
23 pub name: String,
24 pub size_label: Option<String>,
25 pub description: Option<String>,
26 pub capabilities: ModelCapabilities,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct ModelSearchQuery {
31 pub query: String,
32 pub limit: usize,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct ModelDetails {
37 pub id: String,
38 pub name: String,
39 pub source: ModelSource,
40 pub kind: ModelKind,
41 pub model_ref: String,
42 pub download_ref: String,
43 pub path: Option<PathBuf>,
44 pub size_bytes: Option<u64>,
45 pub size_label: Option<String>,
46 pub description: Option<String>,
47 pub draft: Option<String>,
48 pub installed: bool,
49 pub capabilities: ModelCapabilities,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub enum ModelSource {
54 Catalog,
55 HuggingFace,
56 Local,
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum ModelKind {
61 Gguf,
62 Safetensors,
63 LayerPackage,
64 Unknown,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct DownloadedModel {
69 pub model_ref: String,
70 pub paths: Vec<PathBuf>,
71 pub primary_path: Option<PathBuf>,
72 pub details: Option<ModelDetails>,
73}
74
75#[derive(Clone, Debug, Default, Eq, PartialEq)]
76pub struct DeleteModelOptions {
77 pub force: bool,
78}
79
80#[derive(Clone, Debug, Default, Eq, PartialEq)]
81pub struct DeleteModelResult {
82 pub deleted_paths: Vec<PathBuf>,
83 pub reclaimed_bytes: u64,
84}
85
86#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct CleanupPolicy {
88 pub remove_all: bool,
89}
90
91#[derive(Clone, Debug, Default, Eq, PartialEq)]
92pub struct CleanupResult {
93 pub deleted_paths: Vec<PathBuf>,
94 pub reclaimed_bytes: u64,
95 pub skipped_paths: Vec<PathBuf>,
96}
97
98#[derive(Clone, Debug, Default, Eq, PartialEq)]
99pub struct PrunePolicy {
100 pub remove_all: bool,
101}
102
103#[derive(Clone, Debug, Default, Eq, PartialEq)]
104pub struct PruneResult {
105 pub deleted_paths: Vec<PathBuf>,
106 pub reclaimed_bytes: u64,
107}
108
109#[derive(Clone, Debug, Deserialize)]
110struct CatalogAsset {
111 file: String,
112 url: String,
113}
114
115#[derive(Clone, Debug, Deserialize)]
116struct CatalogModel {
117 name: String,
118 file: String,
119 url: String,
120 size: String,
121 description: String,
122 draft: Option<String>,
123 #[serde(default)]
124 extra_files: Vec<CatalogAsset>,
125 mmproj: Option<CatalogAsset>,
126}
127
128pub fn default_huggingface_cache_dir() -> PathBuf {
129 model_hf::huggingface_hub_cache_dir()
130}
131
132pub fn scan_installed_models(cache_dir: impl AsRef<Path>) -> Vec<InstalledModel> {
133 let cache_dir = cache_dir.as_ref();
134 let mut models = Vec::new();
135 if cache_dir.exists() {
136 scan_dir(cache_dir, cache_dir, &mut models);
137 }
138 models.sort_by(|left, right| {
139 left.model_ref
140 .cmp(&right.model_ref)
141 .then_with(|| left.path.cmp(&right.path))
142 });
143 models.dedup_by(|left, right| left.model_ref == right.model_ref && left.path == right.path);
144 models
145}
146
147fn scan_dir(root: &Path, dir: &Path, models: &mut Vec<InstalledModel>) {
148 let Ok(entries) = std::fs::read_dir(dir) else {
149 return;
150 };
151 for entry in entries.flatten() {
152 let path = entry.path();
153 let Ok(file_type) = entry.file_type() else {
154 continue;
155 };
156 if file_type.is_dir() {
157 scan_dir(root, &path, models);
158 } else if file_type.is_file() {
159 maybe_push_model(root, path, models);
160 }
161 }
162}
163
164fn maybe_push_model(root: &Path, path: PathBuf, models: &mut Vec<InstalledModel>) {
165 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
166 return;
167 };
168 if file_name.contains("mmproj") || !is_model_artifact(file_name) {
169 return;
170 }
171 let Some(model_ref) = model_ref_for_path(root, &path) else {
172 return;
173 };
174 let size_bytes = std::fs::metadata(&path).map(|metadata| metadata.len()).ok();
175 let capabilities = infer_local_capabilities(&model_ref, &path);
176 models.push(InstalledModel {
177 model_ref,
178 path,
179 size_bytes,
180 capabilities,
181 });
182}
183
184pub fn recommended_models() -> Vec<ModelSummary> {
185 catalog_models()
186 .into_iter()
187 .map(|model| ModelSummary {
188 id: model.name.clone(),
189 name: model.name.clone(),
190 size_label: Some(model.size.clone()),
191 description: Some(model.description.clone()),
192 capabilities: infer_catalog_capabilities(&model),
193 })
194 .collect()
195}
196
197pub fn search_models(query: ModelSearchQuery, cache_dir: impl AsRef<Path>) -> Vec<ModelSummary> {
198 let needle = query.query.trim().to_ascii_lowercase();
199 let limit = if query.limit == 0 { 20 } else { query.limit };
200 let mut results = recommended_models()
201 .into_iter()
202 .chain(
203 scan_installed_models(cache_dir)
204 .into_iter()
205 .map(ModelSummary::from),
206 )
207 .filter(|model| needle.is_empty() || model_matches(model, &needle))
208 .collect::<Vec<_>>();
209
210 results.sort_by(|left, right| {
211 search_rank(left, &needle)
212 .cmp(&search_rank(right, &needle))
213 .then_with(|| left.name.cmp(&right.name))
214 });
215 results.dedup_by(|left, right| left.id == right.id);
216 results.truncate(limit);
217 results
218}
219
220pub async fn show_model(
221 model_ref: impl AsRef<str>,
222 cache_dir: impl AsRef<Path>,
223) -> Result<ModelDetails> {
224 let input = model_ref.as_ref().trim();
225 if let Some(installed) = scan_installed_models(cache_dir.as_ref())
226 .into_iter()
227 .find(|model| model.model_ref == input)
228 {
229 return Ok(ModelDetails {
230 id: installed.model_ref.clone(),
231 name: installed.model_ref.clone(),
232 source: ModelSource::Local,
233 kind: kind_for_path(&installed.path),
234 model_ref: installed.model_ref.clone(),
235 download_ref: installed.model_ref,
236 path: Some(installed.path),
237 size_bytes: installed.size_bytes,
238 size_label: None,
239 description: None,
240 draft: None,
241 installed: true,
242 capabilities: installed.capabilities,
243 });
244 }
245
246 if let Some(model) = find_catalog_model(input) {
247 let (download_ref, kind) = catalog_download_ref_and_kind(&model);
248 let capabilities = infer_catalog_capabilities(&model);
249 return Ok(ModelDetails {
250 id: model.name.clone(),
251 name: model.name.clone(),
252 source: ModelSource::Catalog,
253 kind,
254 model_ref: model.name,
255 download_ref,
256 path: None,
257 size_bytes: None,
258 size_label: Some(model.size),
259 description: Some(model.description),
260 draft: model.draft,
261 installed: false,
262 capabilities,
263 });
264 }
265
266 let repo = HfModelRepository::builder()
267 .cache_dir(cache_dir.as_ref())
268 .build()
269 .context("build Hugging Face model repository")?;
270 let artifact = resolve_model_artifact_ref(input, &repo).await?;
271 Ok(details_for_artifact(&artifact, None, false))
272}
273
274pub async fn download_model(
275 model_ref: impl AsRef<str>,
276 cache_dir: impl AsRef<Path>,
277) -> Result<DownloadedModel> {
278 let input = model_ref.as_ref().trim();
279 let details = show_model(input, cache_dir.as_ref()).await.ok();
280 if let Some(details) = details.as_ref().filter(|details| details.installed) {
281 let paths = details.path.iter().cloned().collect::<Vec<_>>();
282 return Ok(DownloadedModel {
283 model_ref: details.model_ref.clone(),
284 primary_path: paths.first().cloned(),
285 paths,
286 details: Some(details.clone()),
287 });
288 }
289 let download_ref = details
290 .as_ref()
291 .map(|details| details.download_ref.as_str())
292 .unwrap_or(input);
293 let repo = HfModelRepository::builder()
294 .cache_dir(cache_dir.as_ref())
295 .build()
296 .context("build Hugging Face model repository")?;
297 let artifact = resolve_model_artifact_ref(download_ref, &repo).await?;
298 let paths = repo.download_artifact_files(&artifact).await?;
299 let primary_path = paths.first().cloned();
300 Ok(DownloadedModel {
301 model_ref: artifact.model_id.clone(),
302 paths,
303 primary_path,
304 details: Some(details_for_artifact(&artifact, details, true)),
305 })
306}
307
308pub async fn delete_model(
309 model_ref: impl AsRef<str>,
310 cache_dir: impl AsRef<Path>,
311 _options: DeleteModelOptions,
312) -> Result<DeleteModelResult> {
313 let cache_dir = cache_dir.as_ref();
314 let input = model_ref.as_ref();
315 let installed = scan_installed_models(cache_dir);
316 let matches = installed
317 .into_iter()
318 .filter(|model| model.model_ref == input)
319 .collect::<Vec<_>>();
320 if matches.is_empty() {
321 anyhow::bail!("installed model not found: {input}");
322 }
323 delete_paths(
324 cache_dir,
325 matches.into_iter().map(|model| model.path).collect(),
326 )
327}
328
329pub fn cleanup_models(cache_dir: impl AsRef<Path>, policy: CleanupPolicy) -> Result<CleanupResult> {
330 let cache_dir = cache_dir.as_ref();
331 let installed = scan_installed_models(cache_dir);
332 if !policy.remove_all {
333 return Ok(CleanupResult {
334 skipped_paths: installed.into_iter().map(|model| model.path).collect(),
335 ..CleanupResult::default()
336 });
337 }
338
339 let result = delete_paths(
340 cache_dir,
341 installed.into_iter().map(|model| model.path).collect(),
342 )?;
343 Ok(CleanupResult {
344 deleted_paths: result.deleted_paths,
345 reclaimed_bytes: result.reclaimed_bytes,
346 skipped_paths: Vec::new(),
347 })
348}
349
350pub fn prune_derived_cache(
351 runtime_dir: impl AsRef<Path>,
352 policy: PrunePolicy,
353) -> Result<PruneResult> {
354 let runtime_dir = runtime_dir.as_ref();
355 if !policy.remove_all {
356 return Ok(PruneResult::default());
357 }
358
359 let candidates = [
360 runtime_dir.join("materialized"),
361 runtime_dir.join("skippy-runtime").join("materialized"),
362 ];
363 let mut paths = Vec::new();
364 for candidate in candidates {
365 collect_files(&candidate, &mut paths);
366 }
367 let result = delete_paths(runtime_dir, paths)?;
368 Ok(PruneResult {
369 deleted_paths: result.deleted_paths,
370 reclaimed_bytes: result.reclaimed_bytes,
371 })
372}
373
374fn delete_paths(root: &Path, paths: Vec<PathBuf>) -> Result<DeleteModelResult> {
375 let root = normalize_existing_or_parent(root)?;
376 let mut reclaimed_bytes = 0;
377 let mut deleted_paths = Vec::new();
378 let mut unique_paths = BTreeSet::new();
379
380 for path in paths {
381 let path = normalize_existing_or_parent(&path)?;
382 if !path.starts_with(&root) {
383 anyhow::bail!(
384 "refusing to delete path outside configured root: {}",
385 path.display()
386 );
387 }
388 unique_paths.insert(path);
389 }
390
391 for path in unique_paths {
392 if !path.is_file() {
393 continue;
394 }
395 if let Ok(metadata) = std::fs::metadata(&path) {
396 reclaimed_bytes += metadata.len();
397 }
398 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
399 prune_empty_ancestors(&path, &root);
400 deleted_paths.push(path);
401 }
402
403 Ok(DeleteModelResult {
404 deleted_paths,
405 reclaimed_bytes,
406 })
407}
408
409fn normalize_existing_or_parent(path: &Path) -> Result<PathBuf> {
410 if path.exists() {
411 return Ok(path.canonicalize().unwrap_or_else(|_| path.to_path_buf()));
412 }
413 let Some(parent) = path.parent() else {
414 return Ok(path.to_path_buf());
415 };
416 let parent = parent
417 .canonicalize()
418 .unwrap_or_else(|_| parent.to_path_buf());
419 Ok(parent.join(
420 path.file_name()
421 .map(|value| value.to_owned())
422 .unwrap_or_default(),
423 ))
424}
425
426fn collect_files(dir: &Path, paths: &mut Vec<PathBuf>) {
427 let Ok(entries) = std::fs::read_dir(dir) else {
428 return;
429 };
430 for entry in entries.flatten() {
431 let path = entry.path();
432 let Ok(file_type) = entry.file_type() else {
433 continue;
434 };
435 if file_type.is_dir() {
436 collect_files(&path, paths);
437 } else if file_type.is_file() {
438 paths.push(path);
439 }
440 }
441}
442
443fn prune_empty_ancestors(path: &Path, stop_at: &Path) {
444 let mut current = path.parent();
445 while let Some(dir) = current {
446 if dir == stop_at || !dir.starts_with(stop_at) {
447 break;
448 }
449 match std::fs::remove_dir(dir) {
450 Ok(()) => current = dir.parent(),
451 Err(_) => break,
452 }
453 }
454}
455
456fn details_for_artifact(
457 artifact: &ResolvedModelArtifact,
458 base: Option<ModelDetails>,
459 installed: bool,
460) -> ModelDetails {
461 let capabilities = base
462 .as_ref()
463 .map(|details| details.capabilities)
464 .unwrap_or_else(|| {
465 infer_remote_capabilities(&artifact.source_repo, &artifact.primary_file)
466 });
467 ModelDetails {
468 id: artifact.model_id.clone(),
469 name: artifact.model_id.clone(),
470 source: base
471 .as_ref()
472 .map(|details| details.source.clone())
473 .unwrap_or(ModelSource::HuggingFace),
474 kind: kind_for_artifact_format(artifact.format),
475 model_ref: artifact.model_id.clone(),
476 download_ref: artifact.model_id.clone(),
477 path: None,
478 size_bytes: artifact
479 .files
480 .iter()
481 .filter_map(|file| file.size_bytes)
482 .reduce(|left, right| left.saturating_add(right)),
483 size_label: base.as_ref().and_then(|details| details.size_label.clone()),
484 description: base
485 .as_ref()
486 .and_then(|details| details.description.clone()),
487 draft: base.as_ref().and_then(|details| details.draft.clone()),
488 installed,
489 capabilities,
490 }
491}
492
493fn is_model_artifact(file_name: &str) -> bool {
494 file_name.ends_with(".gguf")
495 || file_name == "model.safetensors"
496 || file_name == "model.safetensors.index.json"
497 || is_split_safetensors_shard(file_name)
498}
499
500fn is_split_safetensors_shard(file_name: &str) -> bool {
501 let Some(rest) = file_name.strip_prefix("model-") else {
502 return false;
503 };
504 let Some(rest) = rest.strip_suffix(".safetensors") else {
505 return false;
506 };
507 let Some((part, total)) = rest.split_once("-of-") else {
508 return false;
509 };
510 part.len() == 5
511 && total.len() == 5
512 && part.bytes().all(|byte| byte.is_ascii_digit())
513 && total.bytes().all(|byte| byte.is_ascii_digit())
514}
515
516fn model_ref_for_path(root: &Path, path: &Path) -> Option<String> {
517 let relative = path.strip_prefix(root).ok()?;
518 let mut components = relative.components();
519 let repo_folder = components.next()?.as_os_str().to_str()?;
520 let repo_id = repo_folder
521 .strip_prefix("models--")
522 .map(|value| value.replace("--", "/"))?;
523 if components.next()?.as_os_str() != "snapshots" {
524 return None;
525 }
526 let _revision = components.next()?.as_os_str().to_str()?;
527 let relative_file = components
528 .map(|component| component.as_os_str().to_str())
529 .collect::<Option<Vec<_>>>()?
530 .join("/");
531
532 if repo_id.ends_with("-layers") && is_layer_package_file(&relative_file) {
533 return Some(format_model_ref(&repo_id, None, None));
534 }
535
536 let selector = quant_selector_from_gguf_file(&relative_file)
537 .or_else(|| normalize_gguf_distribution_id(&relative_file));
538 Some(format_model_ref(&repo_id, None, selector.as_deref()))
539}
540
541fn is_layer_package_file(relative_file: &str) -> bool {
542 relative_file.ends_with(".gguf")
543 && (relative_file.starts_with("shared/") || relative_file.starts_with("layers/"))
544}
545
546fn catalog_models() -> Vec<CatalogModel> {
547 serde_json::from_str(include_str!("catalog.json")).expect("parse bundled model catalog")
548}
549
550fn find_catalog_model(query: &str) -> Option<CatalogModel> {
551 let query_lower = query.to_ascii_lowercase();
552 catalog_models()
553 .into_iter()
554 .find(|model| model.name.eq_ignore_ascii_case(query))
555 .or_else(|| {
556 catalog_models()
557 .into_iter()
558 .find(|model| model.name.to_ascii_lowercase().contains(&query_lower))
559 })
560}
561
562fn catalog_download_ref_and_kind(model: &CatalogModel) -> (String, ModelKind) {
563 if let Some((repo, _revision, file)) = parse_hf_resolve_url_parts(&model.url) {
564 let selector =
565 quant_selector_from_gguf_file(file).or_else(|| normalize_gguf_distribution_id(file));
566 return (
567 format_model_ref(repo, None, selector.as_deref()),
568 kind_for_file(file),
569 );
570 }
571 (model.name.clone(), kind_for_file(&model.file))
572}
573
574fn parse_hf_resolve_url_parts(url: &str) -> Option<(&str, Option<&str>, &str)> {
575 let tail = url
576 .strip_prefix("https://huggingface.co/")
577 .or_else(|| url.strip_prefix("http://huggingface.co/"))?;
578 let (repo, rest) = tail.split_once("/resolve/")?;
579 let (revision, file) = rest.split_once('/')?;
580 Some((repo, Some(revision), file))
581}
582
583fn kind_for_path(path: &Path) -> ModelKind {
584 path.file_name()
585 .and_then(|value| value.to_str())
586 .map(kind_for_file)
587 .unwrap_or(ModelKind::Unknown)
588}
589
590fn kind_for_file(file: &str) -> ModelKind {
591 if file.ends_with(".gguf") {
592 ModelKind::Gguf
593 } else if file.ends_with(".safetensors") || file == "model.safetensors.index.json" {
594 ModelKind::Safetensors
595 } else {
596 ModelKind::Unknown
597 }
598}
599
600fn kind_for_artifact_format(format: ModelFormat) -> ModelKind {
601 match format {
602 ModelFormat::Gguf => ModelKind::Gguf,
603 ModelFormat::Safetensors => ModelKind::Safetensors,
604 }
605}
606
607fn infer_catalog_capabilities(model: &CatalogModel) -> ModelCapabilities {
608 let mut caps = ModelCapabilities::default();
609 if let Some(mmproj) = &model.mmproj {
610 caps.vision = CapabilityLevel::Supported;
611 caps.multimodal = true;
612 caps = merge_name_signals(caps, &[mmproj.file.as_str(), mmproj.url.as_str()]);
613 }
614 let extra_file_signals = model
615 .extra_files
616 .iter()
617 .flat_map(|asset| [asset.file.as_str(), asset.url.as_str()])
618 .collect::<Vec<_>>();
619 caps = merge_name_signals(
620 caps,
621 &[
622 model.name.as_str(),
623 model.file.as_str(),
624 model.description.as_str(),
625 ],
626 );
627 caps = merge_name_signals(caps, &extra_file_signals);
628 caps.normalize()
629}
630
631fn infer_remote_capabilities(repo: &str, file: &str) -> ModelCapabilities {
632 merge_name_signals(ModelCapabilities::default(), &[repo, file]).normalize()
633}
634
635fn infer_local_capabilities(model_ref: &str, path: &Path) -> ModelCapabilities {
636 let mut caps = merge_name_signals(
637 ModelCapabilities::default(),
638 &[
639 model_ref,
640 path.file_name()
641 .and_then(|value| value.to_str())
642 .unwrap_or_default(),
643 ],
644 );
645 for config in read_local_metadata_jsons(path) {
646 caps = merge_config_signals(caps, &config);
647 }
648 caps.normalize()
649}
650
651fn read_local_metadata_jsons(path: &Path) -> Vec<Value> {
652 let mut values = Vec::new();
653 for dir in path.ancestors().skip(1).take(6) {
654 for name in ["config.json", "tokenizer_config.json", "chat_template.json"] {
655 let candidate = dir.join(name);
656 let Ok(text) = std::fs::read_to_string(candidate) else {
657 continue;
658 };
659 if let Ok(value) = serde_json::from_str(&text) {
660 values.push(value);
661 }
662 }
663 }
664 values
665}
666
667impl From<InstalledModel> for ModelSummary {
668 fn from(value: InstalledModel) -> Self {
669 Self {
670 id: value.model_ref.clone(),
671 name: value.model_ref,
672 size_label: value.size_bytes.map(format_size_label),
673 description: value
674 .path
675 .file_name()
676 .and_then(|name| name.to_str())
677 .map(|name| format!("Installed model artifact: {name}")),
678 capabilities: value.capabilities,
679 }
680 }
681}
682
683fn model_matches(model: &ModelSummary, needle: &str) -> bool {
684 let fields = [
685 model.id.as_str(),
686 model.name.as_str(),
687 model.size_label.as_deref().unwrap_or_default(),
688 model.description.as_deref().unwrap_or_default(),
689 ];
690 fields
691 .iter()
692 .any(|field| field.to_ascii_lowercase().contains(needle))
693}
694
695fn search_rank(model: &ModelSummary, needle: &str) -> u8 {
696 if needle.is_empty() {
697 return 0;
698 }
699 let id = model.id.to_ascii_lowercase();
700 let name = model.name.to_ascii_lowercase();
701 if id == needle || name == needle {
702 0
703 } else if id.starts_with(needle) || name.starts_with(needle) {
704 1
705 } else if id.contains(needle) || name.contains(needle) {
706 2
707 } else {
708 3
709 }
710}
711
712fn format_size_label(bytes: u64) -> String {
713 const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
714 const MIB: f64 = 1024.0 * 1024.0;
715 if bytes >= 1024 * 1024 * 1024 {
716 format!("{:.1} GiB", bytes as f64 / GIB)
717 } else if bytes >= 1024 * 1024 {
718 format!("{:.1} MiB", bytes as f64 / MIB)
719 } else {
720 format!("{bytes} bytes")
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn scan_installed_models_finds_hf_snapshot_gguf() {
730 let temp = unique_temp_dir("mesh-llm-node-installed-gguf");
731 let model = temp
732 .join("models--org--repo-GGUF")
733 .join("snapshots")
734 .join("abc")
735 .join("Repo-Q4_K_M.gguf");
736 std::fs::create_dir_all(model.parent().unwrap()).unwrap();
737 std::fs::write(&model, b"gguf").unwrap();
738
739 let installed = scan_installed_models(&temp);
740 assert_eq!(installed.len(), 1);
741 assert_eq!(installed[0].model_ref, "org/repo-GGUF:Q4_K_M");
742 assert_eq!(installed[0].path, model);
743 assert_eq!(installed[0].size_bytes, Some(4));
744 assert_eq!(installed[0].capabilities.reasoning, CapabilityLevel::None);
745
746 let _ = std::fs::remove_dir_all(temp);
747 }
748
749 #[test]
750 fn scan_installed_models_collapses_layer_package_refs() {
751 let temp = unique_temp_dir("mesh-llm-node-installed-layers");
752 let shared = temp
753 .join("models--meshllm--Qwen-layers")
754 .join("snapshots")
755 .join("abc")
756 .join("shared")
757 .join("tok.gguf");
758 let layer = temp
759 .join("models--meshllm--Qwen-layers")
760 .join("snapshots")
761 .join("abc")
762 .join("layers")
763 .join("000.gguf");
764 std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
765 std::fs::create_dir_all(layer.parent().unwrap()).unwrap();
766 std::fs::write(&shared, b"shared").unwrap();
767 std::fs::write(&layer, b"layer").unwrap();
768
769 let installed = scan_installed_models(&temp);
770 assert!(
771 installed
772 .iter()
773 .all(|model| model.model_ref == "meshllm/Qwen-layers")
774 );
775 assert_eq!(installed.len(), 2);
776
777 let _ = std::fs::remove_dir_all(temp);
778 }
779
780 #[test]
781 fn recommended_models_include_capabilities() {
782 let recommended = recommended_models();
783 assert!(!recommended.is_empty());
784 assert!(
785 recommended
786 .iter()
787 .any(|model| model.id == "Qwen3-4B-Q4_K_M")
788 );
789 }
790
791 #[test]
792 fn search_models_finds_catalog_and_installed_models_with_capabilities() {
793 let temp = unique_temp_dir("mesh-llm-node-search");
794 let model = temp
795 .join("models--org--Qwen2-VL-GGUF")
796 .join("snapshots")
797 .join("abc")
798 .join("Qwen2-VL-Q4_K_M.gguf");
799 std::fs::create_dir_all(model.parent().unwrap()).unwrap();
800 std::fs::write(&model, b"gguf").unwrap();
801
802 let catalog = search_models(
803 ModelSearchQuery {
804 query: "qwen3".to_string(),
805 limit: 5,
806 },
807 &temp,
808 );
809 assert!(catalog.iter().any(|model| {
810 model.id.to_ascii_lowercase().contains("qwen3")
811 && model.capabilities.reasoning == CapabilityLevel::Supported
812 }));
813
814 let installed = search_models(
815 ModelSearchQuery {
816 query: "vl".to_string(),
817 limit: 5,
818 },
819 &temp,
820 );
821 assert!(installed.iter().any(|model| {
822 model.id == "org/Qwen2-VL-GGUF:Q4_K_M"
823 && model.capabilities.vision == CapabilityLevel::Supported
824 }));
825
826 let _ = std::fs::remove_dir_all(temp);
827 }
828
829 #[tokio::test]
830 async fn show_model_returns_installed_details_with_capabilities() {
831 let temp = unique_temp_dir("mesh-llm-node-show-installed");
832 let model = temp
833 .join("models--org--Qwen2-VL-GGUF")
834 .join("snapshots")
835 .join("abc")
836 .join("Qwen2-VL-Q4_K_M.gguf");
837 std::fs::create_dir_all(model.parent().unwrap()).unwrap();
838 std::fs::write(&model, b"gguf").unwrap();
839
840 let details = show_model("org/Qwen2-VL-GGUF:Q4_K_M", &temp)
841 .await
842 .expect("show installed model");
843 assert_eq!(details.source, ModelSource::Local);
844 assert_eq!(details.kind, ModelKind::Gguf);
845 assert!(details.installed);
846 assert_eq!(details.path.as_deref(), Some(model.as_path()));
847 assert!(details.capabilities.multimodal);
848
849 let _ = std::fs::remove_dir_all(temp);
850 }
851
852 #[tokio::test]
853 async fn download_model_returns_existing_installed_model_without_network() {
854 let temp = unique_temp_dir("mesh-llm-node-download-installed");
855 let model = temp
856 .join("models--org--repo-GGUF")
857 .join("snapshots")
858 .join("abc")
859 .join("Repo-Q4_K_M.gguf");
860 std::fs::create_dir_all(model.parent().unwrap()).unwrap();
861 std::fs::write(&model, b"gguf").unwrap();
862
863 let downloaded = download_model("org/repo-GGUF:Q4_K_M", &temp)
864 .await
865 .expect("download installed model");
866 assert_eq!(downloaded.model_ref, "org/repo-GGUF:Q4_K_M");
867 assert_eq!(downloaded.primary_path.as_deref(), Some(model.as_path()));
868 assert_eq!(downloaded.paths, vec![model]);
869 assert!(
870 downloaded
871 .details
872 .as_ref()
873 .is_some_and(|details| details.installed)
874 );
875
876 let _ = std::fs::remove_dir_all(temp);
877 }
878
879 #[tokio::test]
880 async fn delete_model_removes_matching_installed_artifact() {
881 let temp = unique_temp_dir("mesh-llm-node-delete-model");
882 let model = temp
883 .join("models--org--repo-GGUF")
884 .join("snapshots")
885 .join("abc")
886 .join("Repo-Q4_K_M.gguf");
887 std::fs::create_dir_all(model.parent().unwrap()).unwrap();
888 std::fs::write(&model, b"gguf").unwrap();
889 let expected_model = model.canonicalize().unwrap();
890
891 let result = delete_model("org/repo-GGUF:Q4_K_M", &temp, DeleteModelOptions::default())
892 .await
893 .expect("delete model");
894 assert_eq!(result.deleted_paths, vec![expected_model]);
895 assert_eq!(result.reclaimed_bytes, 4);
896 assert!(!model.exists());
897
898 let _ = std::fs::remove_dir_all(temp);
899 }
900
901 #[test]
902 fn cleanup_models_requires_opt_in_and_can_remove_all() {
903 let temp = unique_temp_dir("mesh-llm-node-cleanup-models");
904 let model = temp
905 .join("models--org--repo-GGUF")
906 .join("snapshots")
907 .join("abc")
908 .join("Repo-Q4_K_M.gguf");
909 std::fs::create_dir_all(model.parent().unwrap()).unwrap();
910 std::fs::write(&model, b"gguf").unwrap();
911 let expected_model = model.canonicalize().unwrap();
912
913 let skipped = cleanup_models(&temp, CleanupPolicy::default()).expect("cleanup preview");
914 assert!(skipped.deleted_paths.is_empty());
915 assert_eq!(skipped.skipped_paths, vec![model.clone()]);
916 assert!(model.exists());
917
918 let deleted =
919 cleanup_models(&temp, CleanupPolicy { remove_all: true }).expect("cleanup remove all");
920 assert_eq!(deleted.deleted_paths, vec![expected_model]);
921 assert_eq!(deleted.reclaimed_bytes, 4);
922 assert!(!model.exists());
923
924 let _ = std::fs::remove_dir_all(temp);
925 }
926
927 #[test]
928 fn prune_derived_cache_removes_materialized_files_when_enabled() {
929 let temp = unique_temp_dir("mesh-llm-node-prune-derived");
930 let materialized = temp.join("materialized").join("stage.gguf");
931 std::fs::create_dir_all(materialized.parent().unwrap()).unwrap();
932 std::fs::write(&materialized, b"stage").unwrap();
933 let expected_materialized = materialized.canonicalize().unwrap();
934
935 let skipped = prune_derived_cache(&temp, PrunePolicy::default()).expect("prune preview");
936 assert!(skipped.deleted_paths.is_empty());
937 assert!(materialized.exists());
938
939 let pruned =
940 prune_derived_cache(&temp, PrunePolicy { remove_all: true }).expect("prune remove all");
941 assert_eq!(pruned.deleted_paths, vec![expected_materialized]);
942 assert_eq!(pruned.reclaimed_bytes, 5);
943 assert!(!materialized.exists());
944
945 let _ = std::fs::remove_dir_all(temp);
946 }
947
948 fn unique_temp_dir(prefix: &str) -> PathBuf {
949 std::env::temp_dir().join(format!(
950 "{prefix}-{}",
951 std::time::SystemTime::now()
952 .duration_since(std::time::UNIX_EPOCH)
953 .unwrap()
954 .as_nanos()
955 ))
956 }
957}