Skip to main content

mesh_llm_node/
models.rs

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}