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 let hf_cache_root = huggingface_hub_cache_dir();
438 for path in &resolved_paths {
439 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
469fn 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
488fn 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}