Skip to main content

model_hf/store/
delete.rs

1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use hf_hub::cache::{CachedFileInfo, CachedRevisionInfo, HFCacheInfo};
6use hf_hub::{RepoType, RepoTypeModel};
7use model_artifact::{ModelArtifactFile, select_primary_artifact_file};
8
9use super::local::{
10    find_model_path, gguf_metadata_cache_path, huggingface_hub_cache_dir,
11    huggingface_identity_for_path, mesh_llm_cache_dir, scan_hf_cache_info, split_gguf_base_name,
12};
13use super::usage;
14
15#[derive(Clone, Debug)]
16enum DeleteModelRef {
17    LocalStem(String),
18    HuggingFace {
19        repo: String,
20        revision: Option<String>,
21        file: String,
22    },
23}
24
25pub trait DeleteModelCatalog {
26    fn local_stem_for_identifier(&self, identifier: &str) -> Option<String>;
27}
28
29pub struct NoDeleteCatalog;
30
31impl DeleteModelCatalog for NoDeleteCatalog {
32    fn local_stem_for_identifier(&self, _identifier: &str) -> Option<String> {
33        None
34    }
35}
36
37async fn parse_delete_model_ref(
38    input: &str,
39    catalog: &impl DeleteModelCatalog,
40) -> Result<DeleteModelRef> {
41    if input.starts_with("http://") || input.starts_with("https://") {
42        bail!("Delete does not support direct URLs. Use a model stem or Hugging Face ref.");
43    }
44    if Path::new(input).is_absolute()
45        || input.contains('\\')
46        || input.starts_with("./")
47        || input.starts_with("../")
48        || input.starts_with("~/")
49    {
50        bail!("Delete does not support filesystem paths. Use a model stem or Hugging Face ref.");
51    }
52
53    if let Some(stem) = catalog.local_stem_for_identifier(input)
54        && find_model_path(&stem).exists()
55    {
56        return Ok(DeleteModelRef::LocalStem(stem));
57    }
58
59    if !input.contains('/') {
60        let installed_name = input.strip_suffix(".gguf").unwrap_or(input);
61        if find_model_path(installed_name).exists() {
62            return Ok(DeleteModelRef::LocalStem(installed_name.to_string()));
63        }
64    }
65
66    if let Some((repo, revision, file)) = parse_huggingface_file_ref(input) {
67        return Ok(DeleteModelRef::HuggingFace {
68            repo,
69            revision,
70            file,
71        });
72    }
73    if let Some((repo, revision, selector)) = parse_huggingface_repo_ref(input) {
74        return Ok(DeleteModelRef::HuggingFace {
75            repo,
76            revision,
77            file: selector.unwrap_or_default(),
78        });
79    }
80
81    bail!(
82        "Expected a model stem or Hugging Face ref like org/repo, org/repo@rev:QUANT, org/repo/file.gguf, or org/repo/file-stem for split GGUFs."
83    )
84}
85
86fn parse_huggingface_file_ref(input: &str) -> Option<(String, Option<String>, String)> {
87    let parts: Vec<&str> = input.splitn(3, '/').collect();
88    if parts.len() != 3 {
89        return None;
90    }
91    if parts[0].is_empty() || parts[1].is_empty() || parts[0].contains(':') {
92        return None;
93    }
94    let (repo_tail, revision) = match parts[1].split_once('@') {
95        Some((repo, revision)) => (repo, Some(revision.to_string())),
96        None => (parts[1], None),
97    };
98    if repo_tail.is_empty() {
99        return None;
100    }
101    Some((
102        format!("{}/{}", parts[0], repo_tail),
103        revision,
104        parts[2].to_string(),
105    ))
106}
107
108fn parse_huggingface_repo_ref(input: &str) -> Option<(String, Option<String>, Option<String>)> {
109    let parsed = model_ref::ModelRef::parse(input).ok()?;
110    if parsed.repo.split('/').count() != 2 {
111        return None;
112    }
113    Some((parsed.repo, parsed.revision, parsed.selector))
114}
115
116pub async fn resolve_huggingface_file_from_sibling_entries(
117    repo: &str,
118    revision: Option<&str>,
119    file: &str,
120    sibling_entries: &[(String, Option<u64>)],
121) -> Result<String> {
122    if file.ends_with(".gguf")
123        || file.ends_with(".safetensors")
124        || file.ends_with(".safetensors.index.json")
125    {
126        return Ok(file.to_string());
127    }
128
129    let siblings = sibling_entries
130        .iter()
131        .map(|(path, size_bytes)| ModelArtifactFile {
132            path: path.clone(),
133            size_bytes: *size_bytes,
134            sha256: None,
135        })
136        .collect::<Vec<_>>();
137    let has_mlx_weights = siblings
138        .iter()
139        .any(|entry| entry.path == "model.safetensors" || is_split_mlx_first_shard(&entry.path));
140
141    if file == "model" && has_mlx_weights {
142        bail!(
143            "MLX shorthand '/model' is not supported. Use '{repo}' or a full file ref like '{repo}/model.safetensors'."
144        );
145    }
146
147    let selector = (!file.is_empty()).then_some(file);
148    let selected = select_primary_artifact_file(selector, &siblings).with_context(|| {
149        let revision = revision.unwrap_or("main");
150        if file.is_empty() {
151            format!("No model files found in {repo}@{revision}")
152        } else {
153            format!("No model file matching stem '{file}' in {repo}@{revision}")
154        }
155    })?;
156    Ok(selected.path)
157}
158
159fn is_split_mlx_first_shard(file: &str) -> bool {
160    let basename = file.rsplit('/').next().unwrap_or(file);
161    let Some(rest) = basename.strip_prefix("model-") else {
162        return false;
163    };
164    let Some(rest) = rest.strip_suffix(".safetensors") else {
165        return false;
166    };
167    let Some((left, right)) = rest.split_once("-of-") else {
168        return false;
169    };
170    left == "00001" && right.len() == 5 && right.bytes().all(|byte| byte.is_ascii_digit())
171}
172
173#[derive(Debug)]
174pub struct DeleteResult {
175    pub deleted_paths: Vec<PathBuf>,
176    pub reclaimed_bytes: u64,
177    pub removed_metadata_files: usize,
178    pub removed_usage_records: usize,
179    pub removed_derived_cache_files: usize,
180}
181
182pub async fn resolve_model_identifier(identifier: &str) -> Result<Vec<PathBuf>> {
183    resolve_model_identifier_with_catalog(identifier, &NoDeleteCatalog).await
184}
185
186pub async fn resolve_model_identifier_with_catalog(
187    identifier: &str,
188    catalog: &impl DeleteModelCatalog,
189) -> Result<Vec<PathBuf>> {
190    match parse_delete_model_ref(identifier, catalog).await? {
191        DeleteModelRef::LocalStem(stem) => {
192            let path = find_model_path(&stem);
193            if !path.exists() {
194                bail!("Model not found: {}", identifier);
195            }
196            let mut resolved = BTreeSet::from([normalize_path(&path)]);
197            if let Some(cache_info) = scan_hf_cache_info(&huggingface_hub_cache_dir()) {
198                resolved.extend(find_related_hf_cache_paths(&cache_info, &path));
199            }
200            Ok(resolved.into_iter().collect())
201        }
202        DeleteModelRef::HuggingFace {
203            repo,
204            revision,
205            file,
206        } => resolve_cached_hf_ref(&repo, revision.as_deref(), &file)
207            .await
208            .with_context(|| format!("Resolve installed model ref {identifier}")),
209    }
210}
211
212fn normalized_gguf_stem(stem: &str) -> &str {
213    let stem = stem.strip_suffix(".gguf").unwrap_or(stem);
214    split_gguf_base_name(stem).unwrap_or(stem)
215}
216
217async fn resolve_cached_hf_ref(
218    repo_id: &str,
219    revision: Option<&str>,
220    file: &str,
221) -> Result<Vec<PathBuf>> {
222    let cache_root = huggingface_hub_cache_dir();
223    let Some(cache_info) = scan_hf_cache_info(&cache_root) else {
224        bail!("Model not found: {repo_id}");
225    };
226
227    for repo in &cache_info.repos {
228        if repo.repo_type != RepoTypeModel.singular() || repo.repo_id != repo_id {
229            continue;
230        }
231        for cached_revision in &repo.revisions {
232            if revision.is_some_and(|requested| {
233                requested != cached_revision.commit_hash
234                    && !cached_revision.refs.iter().any(|r| r == requested)
235            }) {
236                continue;
237            }
238            if file.is_empty() && repo_id.ends_with("-layers") {
239                if cached_revision
240                    .files
241                    .iter()
242                    .any(|file| is_layered_package_gguf_artifact(cached_revision, file))
243                {
244                    let matches = layered_package_owned_paths(cached_revision);
245                    return Ok(matches);
246                }
247                bail!("Delete only supports GGUF models: {repo_id}");
248            }
249            let sibling_entries: Vec<(String, Option<u64>)> = cached_revision
250                .files
251                .iter()
252                .map(|entry| {
253                    let size = std::fs::metadata(&entry.file_path)
254                        .ok()
255                        .map(|meta| meta.len());
256                    (entry.file_name.clone(), size)
257                })
258                .collect();
259            let resolved_file = resolve_huggingface_file_from_sibling_entries(
260                repo_id,
261                revision.or_else(|| cached_revision.refs.first().map(String::as_str)),
262                file,
263                &sibling_entries,
264            )
265            .await?;
266            if !resolved_file.ends_with(".gguf") {
267                bail!("Delete only supports GGUF models: {repo_id}");
268            }
269            let expected = normalized_gguf_stem(&resolved_file);
270            let mut matches: Vec<PathBuf> = cached_revision
271                .files
272                .iter()
273                .filter(|entry| entry.file_name.ends_with(".gguf"))
274                .filter(|entry| {
275                    normalized_gguf_stem(&entry.file_name).eq_ignore_ascii_case(expected)
276                })
277                .map(|entry| entry.file_path.clone())
278                .collect();
279            if !matches.is_empty() {
280                matches.sort();
281                return Ok(matches);
282            }
283        }
284    }
285
286    bail!("Model not found: {repo_id}")
287}
288
289fn layered_package_owned_paths(revision: &CachedRevisionInfo) -> Vec<PathBuf> {
290    let mut matches: Vec<PathBuf> = revision
291        .files
292        .iter()
293        .map(|file| file.file_path.clone())
294        .collect();
295    matches.sort();
296    matches
297}
298
299fn is_layered_package_gguf_artifact(revision: &CachedRevisionInfo, file: &CachedFileInfo) -> bool {
300    let relative = file
301        .file_path
302        .strip_prefix(&revision.snapshot_path)
303        .unwrap_or(file.file_path.as_path())
304        .to_string_lossy()
305        .replace('\\', "/");
306    (relative.starts_with("shared/") || relative.starts_with("layers/"))
307        && relative.ends_with(".gguf")
308}
309
310fn find_related_hf_cache_paths(cache_info: &HFCacheInfo, path: &Path) -> Vec<PathBuf> {
311    let mut results = BTreeSet::new();
312    let Some(identity) = huggingface_identity_for_path(path) else {
313        return Vec::new();
314    };
315    let Some(file_name) = Path::new(&identity.file)
316        .file_name()
317        .and_then(|value| value.to_str())
318    else {
319        return Vec::new();
320    };
321    let expected = normalized_gguf_stem(file_name);
322
323    for repo in &cache_info.repos {
324        if repo.repo_type != RepoTypeModel.singular() || repo.repo_id != identity.repo_id {
325            continue;
326        }
327        for revision in &repo.revisions {
328            if revision.commit_hash != identity.revision {
329                continue;
330            }
331            for file in &revision.files {
332                if !file.file_name.ends_with(".gguf") {
333                    continue;
334                }
335                if normalized_gguf_stem(&file.file_name).eq_ignore_ascii_case(expected) {
336                    results.insert(file.file_path.clone());
337                }
338            }
339        }
340    }
341
342    results.into_iter().collect()
343}
344
345pub fn collect_delete_paths(resolved_paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
346    let mut to_delete: BTreeSet<PathBuf> = BTreeSet::new();
347    if resolved_paths.is_empty() {
348        return Ok(Vec::new());
349    }
350
351    for path in resolved_paths {
352        ensure_delete_path_allowed(path)?;
353        to_delete.insert(normalize_path(path));
354    }
355
356    let primary_path = &resolved_paths[0];
357    if let Some(record) = usage::load_model_usage_record_for_path(primary_path)
358        && record.mesh_managed
359        && !record.managed_paths.is_empty()
360    {
361        for p in &record.managed_paths {
362            to_delete.insert(normalize_path(p));
363        }
364    }
365
366    Ok(to_delete.into_iter().collect())
367}
368
369pub async fn delete_model_by_identifier(identifier: &str) -> Result<DeleteResult> {
370    delete_model_by_identifier_with_catalog(identifier, &NoDeleteCatalog).await
371}
372
373pub async fn delete_model_by_identifier_with_catalog(
374    identifier: &str,
375    catalog: &impl DeleteModelCatalog,
376) -> Result<DeleteResult> {
377    let resolved_paths = resolve_model_identifier_with_catalog(identifier, catalog).await?;
378
379    if resolved_paths.is_empty() {
380        bail!("Model not found: {}", identifier);
381    }
382
383    let all_paths = collect_delete_paths(&resolved_paths)?;
384
385    if all_paths.is_empty() {
386        bail!(
387            "No GGUF files found at resolved path: {}",
388            resolved_paths[0].display()
389        );
390    }
391
392    let mut reclaimed_bytes: u64 = 0;
393    let mut removed_metadata_files: usize = 0;
394    let mut removed_usage_records: usize = 0;
395    let mut deleted_paths: Vec<PathBuf> = Vec::new();
396    let mut removed_record_paths = BTreeSet::new();
397
398    for path in &all_paths {
399        if path.exists() {
400            if let Ok(meta) = std::fs::metadata(path) {
401                reclaimed_bytes += meta.len();
402            }
403            std::fs::remove_file(path).with_context(|| format!("Remove {}", path.display()))?;
404            deleted_paths.push(path.clone());
405
406            if let Some(metadata_path) = gguf_metadata_cache_path(path)
407                && metadata_path.exists()
408            {
409                std::fs::remove_file(&metadata_path).with_context(|| {
410                    format!("Remove metadata cache {}", metadata_path.display())
411                })?;
412                removed_metadata_files += 1;
413            }
414
415            prune_empty_ancestors(path, &huggingface_hub_cache_dir());
416        }
417    }
418
419    for path in &all_paths {
420        if let Some(record) = load_model_usage_record_for_path(path) {
421            let usage_dir = usage::model_usage_cache_dir();
422            let record_path = usage::usage_record_path(&usage_dir, &record.lookup_key);
423            if removed_record_paths.insert(record_path.clone()) && record_path.exists() {
424                std::fs::remove_file(&record_path)
425                    .with_context(|| format!("Remove usage record {}", record_path.display()))?;
426                removed_usage_records += 1;
427            }
428        }
429    }
430
431    // Clean up stale hf_hub cache symlinks that became broken when their blob
432    // targets were deleted (collect_delete_paths canonicalizes symlinks to
433    // blob paths, so the original symlinks in snapshots/<hash>/ are not
434    // cleaned up by the blob-deletion loop above). Without this cleanup the
435    // hf_hub cache still reports the revision as "ready" on re-download but
436    // the actual files are gone, causing a "No such file" error.
437    let hf_cache_root = huggingface_hub_cache_dir();
438    for path in &resolved_paths {
439        // symlink_metadata succeeds for real files AND broken symlinks;
440        // exists() returns false for broken symlinks. Combined they identify
441        // symlinks whose blob target no longer exists.
442        if std::fs::symlink_metadata(path).is_ok() && !path.exists() {
443            match std::fs::remove_file(path) {
444                Ok(()) => {
445                    prune_empty_ancestors(path, &hf_cache_root);
446                }
447                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
448                    prune_empty_ancestors(path, &hf_cache_root);
449                }
450                Err(e) => {
451                    bail!(
452                        "Failed to remove stale cache symlink {}: {e}",
453                        path.display()
454                    );
455                }
456            }
457        }
458    }
459
460    Ok(DeleteResult {
461        deleted_paths,
462        reclaimed_bytes,
463        removed_metadata_files,
464        removed_usage_records,
465        removed_derived_cache_files: 0,
466    })
467}
468
469/// Load a model usage record for a given path.
470fn load_model_usage_record_for_path(path: &std::path::Path) -> Option<usage::ModelUsageRecord> {
471    usage::load_model_usage_record_for_path(path)
472}
473
474fn ensure_delete_path_allowed(path: &Path) -> Result<()> {
475    let normalized = normalize_path(path);
476    let hf_root = normalize_path(&huggingface_hub_cache_dir());
477    let mesh_root = normalize_path(&mesh_llm_cache_dir());
478    if normalized.starts_with(&hf_root) || normalized.starts_with(&mesh_root) {
479        Ok(())
480    } else {
481        bail!(
482            "Deletion target outside known model roots: {}",
483            normalized.display()
484        );
485    }
486}
487
488/// Prune empty ancestor directories up to (but not including) stop_at.
489fn prune_empty_ancestors(path: &std::path::Path, stop_at: &std::path::Path) {
490    let stop_at = normalize_path(stop_at);
491    let mut current = path.parent().map(normalize_path);
492    while let Some(dir) = current {
493        if dir == stop_at {
494            break;
495        }
496        let Ok(mut entries) = std::fs::read_dir(&dir) else {
497            break;
498        };
499        if entries.next().is_some() {
500            break;
501        }
502        if std::fs::remove_dir(&dir).is_err() {
503            break;
504        }
505        current = dir.parent().map(normalize_path);
506    }
507}
508
509fn normalize_path(path: &std::path::Path) -> PathBuf {
510    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn normalized_gguf_stem_collapses_split_shards() {
519        assert_eq!(
520            normalized_gguf_stem("GLM-5-UD-IQ2_XXS-00001-of-00006.gguf"),
521            "GLM-5-UD-IQ2_XXS"
522        );
523        assert_eq!(normalized_gguf_stem("Qwen3-8B-Q4_K_M"), "Qwen3-8B-Q4_K_M");
524    }
525}