1use hf_hub::cache::{CachedFileInfo, CachedRepoInfo, CachedRevisionInfo, HFCacheInfo};
2use hf_hub::{HFClientBuilder, RepoType, RepoTypeModel};
3use model_ref::{format_model_ref, gguf_matches_quant_selector, normalize_gguf_distribution_id};
4use sha2::{Digest, Sha256};
5use std::collections::{HashMap, HashSet};
6use std::ffi::OsStr;
7use std::path::{Path, PathBuf};
8use std::sync::{Mutex, OnceLock};
9use std::time::UNIX_EPOCH;
10
11mod mmproj;
12
13pub use mmproj::find_mmproj_path;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct HuggingFaceModelIdentity {
17 pub repo_id: String,
18 pub revision: String,
19 pub file: String,
20 pub canonical_ref: String,
21 pub local_file_name: String,
22}
23
24static MODEL_REF_PATHS: OnceLock<Mutex<HashMap<String, PathBuf>>> = OnceLock::new();
25
26fn model_ref_paths() -> &'static Mutex<HashMap<String, PathBuf>> {
27 MODEL_REF_PATHS.get_or_init(|| Mutex::new(HashMap::new()))
28}
29
30fn remember_model_ref_path(model_ref: &str, path: &Path) {
31 if let Ok(mut paths) = model_ref_paths().lock() {
32 match paths.get(model_ref) {
33 Some(existing)
34 if model_ref_path_preference_key(existing)
35 <= model_ref_path_preference_key(path) => {}
36 _ => {
37 paths.insert(model_ref.to_string(), path.to_path_buf());
38 }
39 }
40 }
41}
42
43fn remembered_model_ref_path(model_ref: &str) -> Option<PathBuf> {
44 model_ref_paths()
45 .lock()
46 .ok()
47 .and_then(|paths| paths.get(model_ref).cloned())
48 .filter(|path| path.exists())
49}
50
51impl HuggingFaceModelIdentity {
52 #[cfg(test)]
53 pub fn distribution_ref(&self) -> String {
54 format!(
55 "{}@{}/{}",
56 self.repo_id,
57 self.revision,
58 distribution_ref_file(&self.file)
59 )
60 }
61}
62
63#[cfg(test)]
64fn distribution_ref_file(file: &str) -> String {
65 let path = Path::new(file);
66 let file_name = path.file_name().and_then(|value| value.to_str());
67 let Some(file_name) = file_name else {
68 return file.to_string();
69 };
70 let Some(stem) = file_name.strip_suffix(".gguf") else {
71 return file.to_string();
72 };
73 let Some((prefix, suffix)) = stem.rsplit_once("-of-") else {
74 return file.to_string();
75 };
76 let Some((prefix, shard_no)) = prefix.rsplit_once('-') else {
77 return file.to_string();
78 };
79 if shard_no.len() != 5
80 || suffix.len() != 5
81 || !shard_no.chars().all(|ch| ch.is_ascii_digit())
82 || !suffix.chars().all(|ch| ch.is_ascii_digit())
83 {
84 return file.to_string();
85 }
86 path.with_file_name(prefix)
87 .to_string_lossy()
88 .replace('\\', "/")
89}
90
91pub fn huggingface_hub_cache() -> PathBuf {
92 crate::huggingface_hub_cache_dir()
93}
94
95pub fn huggingface_hub_cache_dir() -> PathBuf {
96 crate::huggingface_hub_cache_dir()
97}
98
99pub fn huggingface_repo_folder_name(repo_id: &str, repo_type: impl RepoType) -> String {
100 let type_plural = repo_type.plural();
101 std::iter::once(type_plural)
102 .chain(repo_id.split('/'))
103 .collect::<Vec<_>>()
104 .join("--")
105}
106
107pub fn huggingface_snapshot_path(
108 repo_id: &str,
109 repo_type: impl RepoType,
110 revision: &str,
111) -> PathBuf {
112 huggingface_hub_cache_dir()
113 .join(huggingface_repo_folder_name(repo_id, repo_type))
114 .join("snapshots")
115 .join(revision)
116}
117
118pub fn scan_hf_cache_info(cache_root: &Path) -> Option<HFCacheInfo> {
119 let cache_root = cache_root.to_path_buf();
120 let scan = move || {
121 let runtime = tokio::runtime::Builder::new_current_thread()
122 .enable_all()
123 .build()
124 .ok()?;
125 runtime
126 .block_on(
127 HFClientBuilder::new()
128 .cache_dir(cache_root)
129 .build()
130 .ok()?
131 .scan_cache()
132 .send(),
133 )
134 .ok()
135 };
136
137 if tokio::runtime::Handle::try_current().is_ok() {
138 std::thread::spawn(scan).join().ok().flatten()
139 } else {
140 scan()
141 }
142}
143
144fn cache_repo_id(repo: &CachedRepoInfo) -> Option<&str> {
145 (repo.repo_type == RepoTypeModel.singular()).then_some(repo.repo_id.as_str())
146}
147
148pub fn mesh_llm_cache_dir() -> PathBuf {
149 crate::mesh_llm_cache_dir()
150}
151
152pub fn model_metadata_cache_dir() -> PathBuf {
153 mesh_llm_cache_dir().join("model-meta")
154}
155
156fn parse_model_repo_folder_name(folder: &str) -> Option<String> {
157 folder
158 .strip_prefix("models--")
159 .map(|value| value.replace("--", "/"))
160}
161
162fn identity_from_cache_snapshot_path(
163 path: &Path,
164 cache_root: &Path,
165) -> Option<HuggingFaceModelIdentity> {
166 let relative = path.strip_prefix(cache_root).ok()?;
167 let mut components = relative.components();
168 let repo_folder = components.next()?.as_os_str().to_str()?;
169 let repo_id = parse_model_repo_folder_name(repo_folder)?;
170 if components.next()?.as_os_str() != OsStr::new("snapshots") {
171 return None;
172 }
173 let revision = components.next()?.as_os_str().to_str()?.to_string();
174 let relative_file = components
175 .map(|component| component.as_os_str().to_str())
176 .collect::<Option<Vec<_>>>()?
177 .join("/");
178 if relative_file.is_empty() {
179 return None;
180 }
181 let local_file_name = Path::new(&relative_file)
182 .file_name()
183 .and_then(|value| value.to_str())?
184 .to_string();
185 let canonical_ref = format!("{repo_id}@{revision}/{relative_file}");
186 Some(HuggingFaceModelIdentity {
187 repo_id,
188 revision,
189 file: relative_file,
190 canonical_ref,
191 local_file_name,
192 })
193}
194
195fn identity_from_snapshot_layout_ancestors(path: &Path) -> Option<HuggingFaceModelIdentity> {
196 for revision_dir in path.ancestors() {
197 let Some(snapshots_dir) = revision_dir.parent() else {
198 continue;
199 };
200 if snapshots_dir.file_name()? != OsStr::new("snapshots") {
201 continue;
202 }
203 let repo_dir = snapshots_dir.parent()?;
204 let repo_folder = repo_dir.file_name()?.to_str()?;
205 let repo_id = parse_model_repo_folder_name(repo_folder)?;
206 let revision = revision_dir.file_name()?.to_str()?.to_string();
207 let relative_file = path
208 .strip_prefix(revision_dir)
209 .ok()?
210 .components()
211 .map(|component| component.as_os_str().to_str())
212 .collect::<Option<Vec<_>>>()?
213 .join("/");
214 if relative_file.is_empty() {
215 continue;
216 }
217 let local_file_name = Path::new(&relative_file)
218 .file_name()
219 .and_then(|value| value.to_str())?
220 .to_string();
221 let canonical_ref = format!("{repo_id}@{revision}/{relative_file}");
222 return Some(HuggingFaceModelIdentity {
223 repo_id,
224 revision,
225 file: relative_file,
226 canonical_ref,
227 local_file_name,
228 });
229 }
230 None
231}
232
233fn scan_hf_cache_identity_for_path(
234 path: &Path,
235 cache_root: &Path,
236) -> Option<HuggingFaceModelIdentity> {
237 let cache_info = scan_hf_cache_info(cache_root)?;
238 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
239
240 for repo in &cache_info.repos {
241 let Some(repo_id) = cache_repo_id(repo) else {
242 continue;
243 };
244 for revision in &repo.revisions {
245 for file in &revision.files {
246 let candidate = file
247 .file_path
248 .canonicalize()
249 .unwrap_or_else(|_| file.file_path.clone());
250 if file.file_path != path && candidate != resolved {
251 continue;
252 }
253
254 let relative_path = file
255 .file_path
256 .strip_prefix(&revision.snapshot_path)
257 .ok()?
258 .to_string_lossy()
259 .replace('\\', "/");
260 if relative_path.is_empty() {
261 return None;
262 }
263
264 let canonical_ref = format!(
265 "{repo_id}@{revision}/{relative_path}",
266 revision = revision.commit_hash
267 );
268
269 return Some(HuggingFaceModelIdentity {
270 repo_id: repo_id.to_string(),
271 revision: revision.commit_hash.clone(),
272 file: relative_path,
273 canonical_ref,
274 local_file_name: file.file_name.clone(),
275 });
276 }
277 }
278 }
279
280 None
281}
282
283pub fn huggingface_identity_for_path(path: &Path) -> Option<HuggingFaceModelIdentity> {
284 let cache_root = huggingface_hub_cache_dir();
285 if let Some(identity) = identity_from_cache_snapshot_path(path, &cache_root) {
286 return Some(identity);
287 }
288 let resolved_cache_root = cache_root
289 .canonicalize()
290 .unwrap_or_else(|_| cache_root.clone());
291 if resolved_cache_root != *cache_root
292 && let Some(identity) = identity_from_cache_snapshot_path(path, &resolved_cache_root)
293 {
294 return Some(identity);
295 }
296 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
297 if resolved != path {
298 if let Some(identity) = identity_from_cache_snapshot_path(&resolved, &cache_root) {
299 return Some(identity);
300 }
301 if resolved_cache_root != *cache_root
302 && let Some(identity) =
303 identity_from_cache_snapshot_path(&resolved, &resolved_cache_root)
304 {
305 return Some(identity);
306 }
307 }
308 if let Some(identity) = identity_from_snapshot_layout_ancestors(path) {
309 return Some(identity);
310 }
311 if resolved != path
312 && let Some(identity) = identity_from_snapshot_layout_ancestors(&resolved)
313 {
314 return Some(identity);
315 }
316 scan_hf_cache_identity_for_path(path, &cache_root)
317}
318
319pub fn gguf_metadata_cache_path(path: &Path) -> Option<PathBuf> {
320 let key = if let Some(identity) = huggingface_identity_for_path(path) {
321 format!("hf:{}", identity.canonical_ref)
322 } else {
323 let metadata = std::fs::metadata(path).ok()?;
324 let modified = metadata
325 .modified()
326 .ok()?
327 .duration_since(UNIX_EPOCH)
328 .ok()?
329 .as_nanos();
330 format!(
331 "local:{}:{}:{}",
332 path.to_string_lossy(),
333 metadata.len(),
334 modified
335 )
336 };
337 let digest = Sha256::digest(key.as_bytes());
338 Some(model_metadata_cache_dir().join(format!("{digest:x}.json")))
339}
340
341pub fn direct_hf_cache_root_gguf_paths(root: &Path) -> Vec<PathBuf> {
342 let mut out = Vec::new();
343 let Ok(entries) = std::fs::read_dir(root) else {
344 return out;
345 };
346 for entry in entries.flatten() {
347 let path = entry.path();
348 let Ok(file_type) = entry.file_type() else {
349 continue;
350 };
351 if !(file_type.is_file() || file_type.is_symlink()) {
352 continue;
353 }
354 if path
355 .extension()
356 .and_then(|ext| ext.to_str())
357 .map(|ext| ext.eq_ignore_ascii_case("gguf"))
358 != Some(true)
359 {
360 continue;
361 }
362 out.push(path);
363 }
364 out.sort();
365 out
366}
367
368fn cache_scanned_file_path(
369 cache_root: &Path,
370 repo: &CachedRepoInfo,
371 revision: &CachedRevisionInfo,
372 file: &CachedFileInfo,
373) -> PathBuf {
374 let relative = file
375 .file_path
376 .strip_prefix(&revision.snapshot_path)
377 .unwrap_or(file.file_path.as_path());
378 repo.repo_path
379 .strip_prefix(cache_root)
380 .map_or_else(
381 |_| repo.repo_path.clone(),
382 |relative| cache_root.join(relative),
383 )
384 .join("snapshots")
385 .join(&revision.commit_hash)
386 .join(relative)
387}
388
389fn cached_relative_file(revision: &CachedRevisionInfo, file: &CachedFileInfo) -> String {
390 file.file_path
391 .strip_prefix(&revision.snapshot_path)
392 .unwrap_or(file.file_path.as_path())
393 .to_string_lossy()
394 .replace('\\', "/")
395}
396
397fn layered_package_relative_preference(relative_file: &str) -> u8 {
398 if relative_file == "shared/output.gguf" {
399 0
400 } else if is_layered_package_direct_shared_relative_file(relative_file) {
401 1
402 } else if layered_package_layer_index(relative_file).is_some() {
403 2
404 } else if is_layered_package_gguf_relative_file(relative_file) {
405 3
406 } else {
407 4
408 }
409}
410
411fn model_ref_path_preference_key(path: &Path) -> (u8, String) {
412 let rank = huggingface_identity_for_path(path)
413 .filter(|identity| identity.repo_id.ends_with("-layers"))
414 .map(|identity| layered_package_relative_preference(&identity.file))
415 .unwrap_or(0);
416 (rank, path.to_string_lossy().to_string())
417}
418
419fn model_ref_path_preference_key_for_cache_root(root: &Path, path: &Path) -> (u8, String) {
420 let rank = identity_from_cache_snapshot_path(path, root)
421 .filter(|identity| identity.repo_id.ends_with("-layers"))
422 .map(|identity| layered_package_relative_preference(&identity.file))
423 .unwrap_or(0);
424 (rank, path.to_string_lossy().to_string())
425}
426
427fn push_model_name(
428 path: &Path,
429 names: &mut Vec<String>,
430 seen: &mut HashSet<String>,
431 min_size_bytes: u64,
432) {
433 if path.extension().and_then(|ext| ext.to_str()) != Some("gguf") {
434 return;
435 }
436 let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
437 return;
438 };
439 if stem.contains("mmproj") {
440 return;
441 }
442 let size = std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
443 if size <= min_size_bytes {
444 return;
445 }
446 let name = model_ref_for_path(path);
447 if seen.insert(name.clone()) {
448 names.push(name);
449 }
450}
451
452fn scan_hf_cache_models(
453 cache_root: &Path,
454 names: &mut Vec<String>,
455 seen: &mut HashSet<String>,
456 min_size_bytes: u64,
457) {
458 for path in direct_hf_cache_root_gguf_paths(cache_root) {
459 push_model_name(&path, names, seen, min_size_bytes);
460 }
461
462 if std::env::var("MESH_LLM_ALLOW_FULL_HF_CACHE_SCAN").unwrap_or_default() == "1" {
463 let Some(cache_info) = scan_hf_cache_info(cache_root) else {
464 return;
465 };
466 for repo in &cache_info.repos {
467 if repo.repo_type != RepoTypeModel.singular() {
468 continue;
469 }
470 for revision in &repo.revisions {
471 let mut files = revision.files.iter().collect::<Vec<_>>();
472 files.sort_by(|left, right| {
473 let left_relative = cached_relative_file(revision, left);
474 let right_relative = cached_relative_file(revision, right);
475 layered_package_relative_preference(&left_relative)
476 .cmp(&layered_package_relative_preference(&right_relative))
477 .then_with(|| left_relative.cmp(&right_relative))
478 });
479 for file in files {
480 if !file.file_name.ends_with(".gguf") {
481 continue;
482 }
483 let path = cache_scanned_file_path(cache_root, repo, revision, file);
484 push_model_name(&path, names, seen, min_size_bytes);
485 }
486 }
487 }
488 } else {
489 for path in scan_hf_cache_fast(cache_root) {
490 push_model_name(&path, names, seen, min_size_bytes);
491 }
492 }
493}
494
495fn scan_models_with_min_size(cache_root: &Path, min_size_bytes: u64) -> Vec<String> {
496 let mut names = Vec::new();
497 let mut seen = HashSet::new();
498 if cache_root.exists() {
499 scan_hf_cache_models(cache_root, &mut names, &mut seen, min_size_bytes);
500 }
501 names.sort();
502 names
503}
504
505pub fn scan_local_models() -> Vec<String> {
507 scan_models_with_min_size(&huggingface_hub_cache_dir(), 500_000_000)
508}
509
510pub fn scan_installed_models() -> Vec<String> {
512 scan_installed_models_in(&huggingface_hub_cache_dir())
513}
514
515pub fn scan_installed_models_in(cache_root: &Path) -> Vec<String> {
517 scan_models_with_min_size(cache_root, 0)
518}
519
520fn hf_identity_model_ref(identity: &HuggingFaceModelIdentity) -> String {
521 if let Some(model_ref) = layered_package_model_ref(identity) {
522 return model_ref;
523 }
524 let selector = model_ref::quant_selector_from_gguf_file(&identity.file)
525 .or_else(|| normalize_gguf_distribution_id(&identity.file));
526 format_model_ref(&identity.repo_id, None, selector.as_deref())
527}
528
529fn layered_package_model_ref(identity: &HuggingFaceModelIdentity) -> Option<String> {
530 if identity.repo_id.ends_with("-layers")
531 && is_layered_package_gguf_relative_file(&identity.file)
532 {
533 Some(format_model_ref(&identity.repo_id, None, None))
534 } else {
535 None
536 }
537}
538
539fn is_layered_package_gguf_relative_file(relative_file: &str) -> bool {
540 (relative_file.starts_with("shared/") || relative_file.starts_with("layers/"))
541 && relative_file.ends_with(".gguf")
542 && Path::new(relative_file).file_name().is_some()
543}
544
545fn is_layered_package_direct_shared_relative_file(relative_file: &str) -> bool {
546 let Some(file_name) = relative_file.strip_prefix("shared/") else {
547 return false;
548 };
549 !file_name.is_empty() && !file_name.contains('/') && file_name.ends_with(".gguf")
550}
551
552fn layered_package_layer_index(relative_file: &str) -> Option<usize> {
553 let relative = relative_file.strip_prefix("layers/")?;
554 let file_name = Path::new(relative).file_name()?.to_str()?;
555 let index = file_name.strip_prefix("layer-")?.strip_suffix(".gguf")?;
556 if index.is_empty() || !index.chars().all(|ch| ch.is_ascii_digit()) {
557 return None;
558 }
559 index.parse().ok()
560}
561
562fn layered_package_snapshot_root(
563 path: &Path,
564 identity: &HuggingFaceModelIdentity,
565) -> Option<PathBuf> {
566 let mut root = path.to_path_buf();
567 for _ in Path::new(&identity.file).components() {
568 if !root.pop() {
569 return None;
570 }
571 }
572 Some(root)
573}
574
575fn layered_package_gguf_paths(path: &Path) -> Option<(PathBuf, Vec<PathBuf>)> {
576 let identity = huggingface_identity_for_path(path)?;
577 layered_package_model_ref(&identity)?;
578 let root = layered_package_snapshot_root(path, &identity)?;
579 let mut paths = Vec::new();
580 for subdir in ["shared", "layers"] {
581 collect_gguf_paths_recursive(&root.join(subdir), &mut paths);
582 }
583 paths.sort();
584 Some((root, paths))
585}
586
587fn collect_gguf_paths_recursive(dir: &Path, paths: &mut Vec<PathBuf>) {
588 let Ok(entries) = std::fs::read_dir(dir) else {
589 return;
590 };
591 for entry in entries.flatten() {
592 let path = entry.path();
593 if path.is_dir() {
594 collect_gguf_paths_recursive(&path, paths);
595 continue;
596 }
597 if path
598 .extension()
599 .and_then(|ext| ext.to_str())
600 .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
601 {
602 paths.push(path);
603 }
604 }
605}
606
607pub fn scan_hf_cache_fast(cache_root: &Path) -> Vec<PathBuf> {
608 let mut gguf_paths = Vec::new();
609 let Ok(entries) = std::fs::read_dir(cache_root) else {
610 return gguf_paths;
611 };
612 for entry in entries.flatten() {
613 let path = entry.path();
614 if path.is_dir() {
615 let snapshots = path.join("snapshots");
616 if snapshots.exists() {
617 collect_gguf_paths_recursive(&snapshots, &mut gguf_paths);
618 }
619 }
620 }
621 gguf_paths
622}
623
624pub fn layered_package_layer_count_for_path(path: &Path) -> Option<usize> {
625 let (root, paths) = layered_package_gguf_paths(path)?;
626 let layers = paths
627 .iter()
628 .filter(|path| {
629 path.strip_prefix(&root)
630 .ok()
631 .map(|relative| relative.to_string_lossy().replace('\\', "/"))
632 .as_deref()
633 .and_then(layered_package_layer_index)
634 .is_some()
635 })
636 .count();
637 (layers > 0).then_some(layers)
638}
639
640pub fn layered_package_total_bytes_for_path(path: &Path) -> Option<u64> {
641 let (_root, paths) = layered_package_gguf_paths(path)?;
642 let total = paths
643 .iter()
644 .map(|path| std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0))
645 .sum();
646 Some(total)
647}
648
649fn synthetic_local_gguf_model_ref(path: &Path) -> String {
650 let filename = path
651 .file_name()
652 .and_then(|value| value.to_str())
653 .unwrap_or("model.gguf");
654 let metadata = std::fs::metadata(path).ok();
655 let len = metadata.as_ref().map(std::fs::Metadata::len).unwrap_or(0);
656 let modified = metadata
657 .and_then(|metadata| metadata.modified().ok())
658 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
659 .map(|duration| duration.as_nanos())
660 .unwrap_or(0);
661 let mut hasher = Sha256::new();
662 hasher.update(path.to_string_lossy().as_bytes());
663 hasher.update(b"\0");
664 hasher.update(filename.as_bytes());
665 hasher.update(b"\0");
666 hasher.update(len.to_le_bytes());
667 hasher.update(modified.to_le_bytes());
668 let digest = format!("{:x}", hasher.finalize());
669 format_model_ref(&format!("local-gguf/sha256-{}", &digest[..16]), None, None)
670}
671
672pub fn model_ref_for_path(path: &Path) -> String {
673 let model_ref = huggingface_identity_for_path(path)
674 .map(|identity| hf_identity_model_ref(&identity))
675 .unwrap_or_else(|| synthetic_local_gguf_model_ref(path));
676 remember_model_ref_path(&model_ref, path);
677 model_ref
678}
679
680fn find_hf_cache_model_ref_path(root: &Path, model: &model_ref::ModelRef) -> Option<PathBuf> {
681 if model.repo.starts_with("local-gguf/") {
682 return find_synthetic_local_gguf_path(root, model);
683 }
684 let cache_info = scan_hf_cache_info(root)?;
685 let mut candidates = Vec::new();
686 for repo in &cache_info.repos {
687 if repo.repo_type != RepoTypeModel.singular() || repo.repo_id != model.repo {
688 continue;
689 }
690 for revision in &repo.revisions {
691 if let Some(wanted_revision) = model.revision.as_deref()
692 && revision.commit_hash != wanted_revision
693 {
694 continue;
695 }
696 for file in &revision.files {
697 if !file.file_name.ends_with(".gguf") {
698 continue;
699 }
700 let matches = match model.selector.as_deref() {
701 Some(selector) => {
702 gguf_matches_quant_selector(&file.file_name, selector)
703 || normalize_gguf_distribution_id(&file.file_name).as_deref()
704 == Some(selector)
705 }
706 None => true,
707 };
708 if matches {
709 candidates.push(cache_scanned_file_path(root, repo, revision, file));
710 }
711 }
712 }
713 }
714 candidates.sort_by_key(|path| model_ref_path_preference_key_for_cache_root(root, path));
715 candidates.into_iter().next()
716}
717
718fn find_synthetic_local_gguf_path(root: &Path, model: &model_ref::ModelRef) -> Option<PathBuf> {
719 let wanted = model.display_id();
720 let mut candidates = direct_hf_cache_root_gguf_paths(root);
721 let cache_info = scan_hf_cache_info(root);
722 if let Some(cache_info) = cache_info {
723 for repo in &cache_info.repos {
724 if repo.repo_type != RepoTypeModel.singular() {
725 continue;
726 }
727 for revision in &repo.revisions {
728 for file in &revision.files {
729 if file.file_name.ends_with(".gguf") {
730 candidates.push(cache_scanned_file_path(root, repo, revision, file));
731 }
732 }
733 }
734 }
735 }
736 candidates.sort();
737 candidates
738 .into_iter()
739 .find(|path| model_ref_for_path(path) == wanted)
740}
741
742fn find_hf_cache_model_path(root: &Path, stem: &str) -> Option<PathBuf> {
743 let filename = format!("{stem}.gguf");
744 let direct = root.join(&filename);
745 if direct.exists() {
746 return Some(direct);
747 }
748
749 let split_prefix = format!("{stem}-00001-of-");
750 let cache_root = huggingface_hub_cache_dir();
751 let cache_info = scan_hf_cache_info(&cache_root)?;
752 for repo in &cache_info.repos {
753 if repo.repo_type != RepoTypeModel.singular() {
754 continue;
755 }
756 for revision in &repo.revisions {
757 for file in &revision.files {
758 let Some(name) = Path::new(&file.file_name)
759 .file_name()
760 .and_then(|value| value.to_str())
761 else {
762 continue;
763 };
764 if name == filename || (name.starts_with(&split_prefix) && name.ends_with(".gguf"))
765 {
766 return Some(cache_scanned_file_path(&cache_root, repo, revision, file));
767 }
768 }
769 }
770 }
771 None
772}
773
774pub fn split_gguf_base_name(stem: &str) -> Option<&str> {
778 let suffix = stem.rfind("-of-")?;
779 let part_num = &stem[suffix + 4..];
780 if part_num.len() != 5 || !part_num.chars().all(|c| c.is_ascii_digit()) {
781 return None;
782 }
783 let dash = stem[..suffix].rfind('-')?;
784 let seq = &stem[dash + 1..suffix];
785 if seq.len() != 5 || !seq.chars().all(|c| c.is_ascii_digit()) {
786 return None;
787 }
788 Some(&stem[..dash])
789}
790
791pub fn find_model_path(model_ref: &str) -> PathBuf {
794 if let Some(path) = remembered_model_ref_path(model_ref) {
795 return path;
796 }
797 let canonical_dir = huggingface_hub_cache_dir();
798 if let Ok(parsed) = model_ref::ModelRef::parse(model_ref)
799 && let Some(found) = find_hf_cache_model_ref_path(&canonical_dir, &parsed)
800 {
801 return found;
802 }
803
804 if let Some(found) = find_hf_cache_model_path(&canonical_dir, model_ref) {
805 return found;
806 }
807
808 canonical_dir.join(format!("{model_ref}.gguf"))
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use serial_test::serial;
815
816 #[test]
817 #[serial]
818 fn huggingface_cache_prefers_explicit_hub_cache() {
819 let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
820 let prev_huggingface_hub_cache = std::env::var_os("HUGGINGFACE_HUB_CACHE");
821 let prev_hf_home = std::env::var_os("HF_HOME");
822 let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
823 unsafe { std::env::set_var("HF_HUB_CACHE", "/tmp/mesh-llm-hub-cache") };
825 unsafe { std::env::set_var("HUGGINGFACE_HUB_CACHE", "/tmp/mesh-llm-alt-hub-cache") };
827 unsafe { std::env::set_var("HF_HOME", "/tmp/mesh-llm-hf-home") };
829 unsafe { std::env::set_var("XDG_CACHE_HOME", "/tmp/mesh-llm-xdg") };
831
832 assert_eq!(
833 huggingface_hub_cache_dir(),
834 PathBuf::from("/tmp/mesh-llm-hub-cache")
835 );
836
837 restore_env("HF_HUB_CACHE", prev_hub_cache);
838 restore_env("HUGGINGFACE_HUB_CACHE", prev_huggingface_hub_cache);
839 restore_env("HF_HOME", prev_hf_home);
840 restore_env("XDG_CACHE_HOME", prev_xdg);
841 }
842
843 #[test]
844 #[serial]
845 fn huggingface_cache_accepts_huggingface_hub_cache_alias() {
846 let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
847 let prev_huggingface_hub_cache = std::env::var_os("HUGGINGFACE_HUB_CACHE");
848 let prev_hf_home = std::env::var_os("HF_HOME");
849 let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
850 unsafe { std::env::remove_var("HF_HUB_CACHE") };
852 unsafe { std::env::set_var("HUGGINGFACE_HUB_CACHE", "/tmp/mesh-llm-alt-hub-cache") };
854 unsafe { std::env::set_var("HF_HOME", "/tmp/mesh-llm-hf-home") };
856 unsafe { std::env::set_var("XDG_CACHE_HOME", "/tmp/mesh-llm-xdg") };
858
859 assert_eq!(
860 huggingface_hub_cache_dir(),
861 PathBuf::from("/tmp/mesh-llm-alt-hub-cache")
862 );
863
864 restore_env("HF_HUB_CACHE", prev_hub_cache);
865 restore_env("HUGGINGFACE_HUB_CACHE", prev_huggingface_hub_cache);
866 restore_env("HF_HOME", prev_hf_home);
867 restore_env("XDG_CACHE_HOME", prev_xdg);
868 }
869
870 #[test]
871 #[serial]
872 fn huggingface_cache_falls_back_to_hf_home() {
873 let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
874 let prev_huggingface_hub_cache = std::env::var_os("HUGGINGFACE_HUB_CACHE");
875 let prev_hf_home = std::env::var_os("HF_HOME");
876 let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
877 unsafe { std::env::remove_var("HF_HUB_CACHE") };
879 unsafe { std::env::remove_var("HUGGINGFACE_HUB_CACHE") };
881 unsafe { std::env::set_var("HF_HOME", "/tmp/mesh-llm-hf-home") };
883 unsafe { std::env::set_var("XDG_CACHE_HOME", "/tmp/mesh-llm-xdg") };
885
886 assert_eq!(
887 huggingface_hub_cache_dir(),
888 PathBuf::from("/tmp/mesh-llm-hf-home").join("hub")
889 );
890
891 restore_env("HF_HUB_CACHE", prev_hub_cache);
892 restore_env("HUGGINGFACE_HUB_CACHE", prev_huggingface_hub_cache);
893 restore_env("HF_HOME", prev_hf_home);
894 restore_env("XDG_CACHE_HOME", prev_xdg);
895 }
896
897 #[test]
898 fn test_split_gguf_base_name() {
899 assert_eq!(
900 split_gguf_base_name("GLM-5-UD-IQ2_XXS-00001-of-00006"),
901 Some("GLM-5-UD-IQ2_XXS")
902 );
903 assert_eq!(
904 split_gguf_base_name("GLM-5-UD-IQ2_XXS-00006-of-00006"),
905 Some("GLM-5-UD-IQ2_XXS")
906 );
907 assert_eq!(split_gguf_base_name("Qwen3-8B-Q4_K_M"), None);
908 assert_eq!(split_gguf_base_name("model-001-of-003"), None);
909 assert_eq!(split_gguf_base_name("model-00001-of-00003"), Some("model"));
910 }
911
912 #[test]
913 #[serial]
914 fn huggingface_identity_for_path_parses_snapshot_path_directly() {
915 let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
916 let prev_hf_home = std::env::var_os("HF_HOME");
917 let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
918
919 let temp = std::env::temp_dir().join(format!(
920 "mesh-llm-hf-identity-{}",
921 std::time::SystemTime::now()
922 .duration_since(std::time::UNIX_EPOCH)
923 .unwrap()
924 .as_nanos()
925 ));
926 let snapshot_path = temp
927 .join("models--bartowski--Llama-3.2-1B-Instruct-GGUF")
928 .join("snapshots")
929 .join("abcdef1234567890")
930 .join("nested")
931 .join("Llama-3.2-1B-Instruct-Q4_K_M.gguf");
932 std::fs::create_dir_all(snapshot_path.parent().unwrap()).unwrap();
933 std::fs::write(&snapshot_path, b"gguf").unwrap();
934
935 unsafe { std::env::set_var("HF_HUB_CACHE", &temp) };
937 unsafe { std::env::remove_var("HF_HOME") };
939 unsafe { std::env::remove_var("XDG_CACHE_HOME") };
941
942 let identity = huggingface_identity_for_path(&snapshot_path).unwrap();
943 assert_eq!(identity.repo_id, "bartowski/Llama-3.2-1B-Instruct-GGUF");
944 assert_eq!(identity.revision, "abcdef1234567890");
945 assert_eq!(identity.file, "nested/Llama-3.2-1B-Instruct-Q4_K_M.gguf");
946 assert_eq!(
947 identity.canonical_ref,
948 "bartowski/Llama-3.2-1B-Instruct-GGUF@abcdef1234567890/nested/Llama-3.2-1B-Instruct-Q4_K_M.gguf"
949 );
950 assert_eq!(
951 identity.local_file_name,
952 "Llama-3.2-1B-Instruct-Q4_K_M.gguf"
953 );
954
955 let _ = std::fs::remove_dir_all(&temp);
956 restore_env("HF_HUB_CACHE", prev_hub_cache);
957 restore_env("HF_HOME", prev_hf_home);
958 restore_env("XDG_CACHE_HOME", prev_xdg);
959 }
960
961 #[test]
962 #[serial]
963 fn huggingface_identity_for_path_falls_back_to_snapshot_layout_ancestors() {
964 let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
965 let prev_hf_home = std::env::var_os("HF_HOME");
966 let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
967
968 let temp = std::env::temp_dir().join(format!(
969 "mesh-llm-hf-ancestor-{}",
970 std::time::SystemTime::now()
971 .duration_since(std::time::UNIX_EPOCH)
972 .unwrap()
973 .as_nanos()
974 ));
975 let snapshot_path = temp
976 .join("nested")
977 .join("cache-root")
978 .join("models--bartowski--Llama-3.2-1B-Instruct-GGUF")
979 .join("snapshots")
980 .join("abcdef1234567890")
981 .join("nested")
982 .join("Llama-3.2-1B-Instruct-Q4_K_M.gguf");
983 std::fs::create_dir_all(snapshot_path.parent().unwrap()).unwrap();
984 std::fs::write(&snapshot_path, b"gguf").unwrap();
985
986 unsafe { std::env::set_var("HF_HUB_CACHE", temp.join("some-other-cache-root")) };
988 unsafe { std::env::remove_var("HF_HOME") };
990 unsafe { std::env::remove_var("XDG_CACHE_HOME") };
992
993 let identity = huggingface_identity_for_path(&snapshot_path).unwrap();
994 assert_eq!(identity.repo_id, "bartowski/Llama-3.2-1B-Instruct-GGUF");
995 assert_eq!(identity.revision, "abcdef1234567890");
996 assert_eq!(identity.file, "nested/Llama-3.2-1B-Instruct-Q4_K_M.gguf");
997
998 let _ = std::fs::remove_dir_all(&temp);
999 restore_env("HF_HUB_CACHE", prev_hub_cache);
1000 restore_env("HF_HOME", prev_hf_home);
1001 restore_env("XDG_CACHE_HOME", prev_xdg);
1002 }
1003
1004 #[test]
1005 #[serial]
1006 fn scan_installed_models_includes_direct_hf_cache_root_files() {
1007 let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
1008 let prev_hf_home = std::env::var_os("HF_HOME");
1009 let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
1010
1011 let temp = std::env::temp_dir().join(format!(
1012 "mesh-llm-direct-cache-root-{}",
1013 std::time::SystemTime::now()
1014 .duration_since(std::time::UNIX_EPOCH)
1015 .unwrap()
1016 .as_nanos()
1017 ));
1018 std::fs::create_dir_all(&temp).unwrap();
1019 std::fs::write(temp.join("Direct-Root-Q4_K_M.gguf"), b"gguf").unwrap();
1020
1021 unsafe { std::env::set_var("HF_HUB_CACHE", &temp) };
1023 unsafe { std::env::remove_var("HF_HOME") };
1025 unsafe { std::env::remove_var("XDG_CACHE_HOME") };
1027
1028 let installed = scan_installed_models();
1029 assert!(
1030 installed
1031 .iter()
1032 .any(|name| name.starts_with("local-gguf/sha256-"))
1033 );
1034
1035 let _ = std::fs::remove_dir_all(&temp);
1036 restore_env("HF_HUB_CACHE", prev_hub_cache);
1037 restore_env("HF_HOME", prev_hf_home);
1038 restore_env("XDG_CACHE_HOME", prev_xdg);
1039 }
1040
1041 #[test]
1042 #[serial]
1043 fn scan_installed_models_collapses_layered_package_files() {
1044 if let Ok(mut paths) = model_ref_paths().lock() {
1045 paths.clear();
1046 }
1047 let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
1048 let prev_hf_home = std::env::var_os("HF_HOME");
1049 let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
1050
1051 let temp = std::env::temp_dir().join(format!(
1052 "mesh-llm-layered-cache-{}",
1053 std::time::SystemTime::now()
1054 .duration_since(std::time::UNIX_EPOCH)
1055 .unwrap()
1056 .as_nanos()
1057 ));
1058 let repo_dir = temp.join("models--meshllm--DeepSeek-V3.2-UD-Q4_K_XL-layers");
1059 let revision = "abcdef1234567890";
1060 let snapshot = repo_dir.join("snapshots").join(revision);
1061 let shared = snapshot.join("shared").join("embeddings.gguf");
1062 let layer_000 = snapshot.join("layers").join("layer-000.gguf");
1063 let layer_001 = snapshot.join("layers").join("layer-001.gguf");
1064 let nested_layer_002 = snapshot
1065 .join("layers")
1066 .join("blocks")
1067 .join("layer-002.gguf");
1068 let nested_shared = snapshot.join("shared").join("nested").join("extra.gguf");
1069 std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
1070 std::fs::create_dir_all(layer_000.parent().unwrap()).unwrap();
1071 std::fs::create_dir_all(nested_layer_002.parent().unwrap()).unwrap();
1072 std::fs::create_dir_all(nested_shared.parent().unwrap()).unwrap();
1073 std::fs::create_dir_all(repo_dir.join("refs")).unwrap();
1074 std::fs::write(repo_dir.join("refs").join("main"), revision).unwrap();
1075 std::fs::write(&shared, b"shared").unwrap();
1076 std::fs::write(&layer_000, b"layer-000").unwrap();
1077 std::fs::write(&layer_001, b"layer-001").unwrap();
1078 std::fs::write(&nested_layer_002, b"layer-002").unwrap();
1079 std::fs::write(&nested_shared, b"nested").unwrap();
1080
1081 unsafe { std::env::set_var("HF_HUB_CACHE", &temp) };
1083 unsafe { std::env::remove_var("HF_HOME") };
1085 unsafe { std::env::remove_var("XDG_CACHE_HOME") };
1087
1088 let installed = scan_installed_models();
1089
1090 assert_eq!(
1091 installed,
1092 vec!["meshllm/DeepSeek-V3.2-UD-Q4_K_XL-layers".to_string()]
1093 );
1094 let parsed_ref =
1095 model_ref::ModelRef::parse("meshllm/DeepSeek-V3.2-UD-Q4_K_XL-layers").unwrap();
1096 assert_eq!(
1097 find_hf_cache_model_ref_path(&temp, &parsed_ref),
1098 Some(shared.clone())
1099 );
1100 assert_eq!(layered_package_layer_count_for_path(&layer_000), Some(3));
1101 assert_eq!(
1102 layered_package_total_bytes_for_path(&layer_000),
1103 Some(6 + 9 + 9 + 9 + 6)
1104 );
1105
1106 let _ = std::fs::remove_dir_all(&temp);
1107 restore_env("HF_HUB_CACHE", prev_hub_cache);
1108 restore_env("HF_HOME", prev_hf_home);
1109 restore_env("XDG_CACHE_HOME", prev_xdg);
1110 }
1111
1112 #[test]
1113 fn hf_identity_model_ref_preserves_layer_selector_outside_layered_packages() {
1114 let identity = HuggingFaceModelIdentity {
1115 repo_id: "example/Regular-GGUF".to_string(),
1116 revision: "deadbeef".to_string(),
1117 file: "layers/layer-000.gguf".to_string(),
1118 canonical_ref: "example/Regular-GGUF@deadbeef/layers/layer-000.gguf".to_string(),
1119 local_file_name: "layer-000.gguf".to_string(),
1120 };
1121
1122 assert_eq!(
1123 hf_identity_model_ref(&identity),
1124 "example/Regular-GGUF:layer-000"
1125 );
1126 }
1127
1128 #[test]
1129 fn layered_package_shared_matching_accepts_nested_package_artifacts() {
1130 let direct = HuggingFaceModelIdentity {
1131 repo_id: "meshllm/Demo-layers".to_string(),
1132 revision: "deadbeef".to_string(),
1133 file: "shared/embeddings.gguf".to_string(),
1134 canonical_ref: "meshllm/Demo-layers@deadbeef/shared/embeddings.gguf".to_string(),
1135 local_file_name: "embeddings.gguf".to_string(),
1136 };
1137 let nested = HuggingFaceModelIdentity {
1138 file: "shared/nested/embeddings.gguf".to_string(),
1139 canonical_ref: "meshllm/Demo-layers@deadbeef/shared/nested/embeddings.gguf".to_string(),
1140 ..direct.clone()
1141 };
1142
1143 assert_eq!(
1144 layered_package_model_ref(&direct),
1145 Some("meshllm/Demo-layers".to_string())
1146 );
1147 assert_eq!(
1148 layered_package_model_ref(&nested),
1149 Some("meshllm/Demo-layers".to_string())
1150 );
1151 }
1152
1153 #[test]
1154 fn layered_package_layer_matching_is_separator_safe_after_normalization() {
1155 let relative = "layers\\block-0\\layer-000.gguf".replace('\\', "/");
1156
1157 assert_eq!(layered_package_layer_index(&relative), Some(0));
1158 }
1159
1160 #[test]
1161 fn distribution_ref_preserves_unsplit_exact_file() {
1162 let identity = HuggingFaceModelIdentity {
1163 repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_string(),
1164 revision: "deadbeef".to_string(),
1165 file: "BF16/Qwen3.6-35B-A3B-BF16.gguf".to_string(),
1166 canonical_ref: "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16.gguf"
1167 .to_string(),
1168 local_file_name: "Qwen3.6-35B-A3B-BF16.gguf".to_string(),
1169 };
1170
1171 assert_eq!(
1172 identity.distribution_ref(),
1173 "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16.gguf"
1174 );
1175 }
1176
1177 #[test]
1178 fn distribution_ref_strips_split_suffix_for_split_gguf() {
1179 let identity = HuggingFaceModelIdentity {
1180 repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_string(),
1181 revision: "deadbeef".to_string(),
1182 file: "BF16/Qwen3.6-35B-A3B-BF16-00001-of-00002.gguf".to_string(),
1183 canonical_ref: "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16-00001-of-00002.gguf".to_string(),
1184 local_file_name: "Qwen3.6-35B-A3B-BF16-00001-of-00002.gguf".to_string(),
1185 };
1186
1187 assert_eq!(
1188 identity.distribution_ref(),
1189 "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16"
1190 );
1191 }
1192
1193 #[test]
1194 fn distribution_ref_strips_non_first_split_suffix_for_split_gguf() {
1195 let identity = HuggingFaceModelIdentity {
1196 repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_string(),
1197 revision: "deadbeef".to_string(),
1198 file: "BF16/Qwen3.6-35B-A3B-BF16-00002-of-00002.gguf".to_string(),
1199 canonical_ref: "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16-00002-of-00002.gguf".to_string(),
1200 local_file_name: "Qwen3.6-35B-A3B-BF16-00002-of-00002.gguf".to_string(),
1201 };
1202
1203 assert_eq!(
1204 identity.distribution_ref(),
1205 "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16"
1206 );
1207 }
1208
1209 fn restore_env(key: &str, value: Option<std::ffi::OsString>) {
1210 if let Some(value) = value {
1211 unsafe { std::env::set_var(key, value) };
1213 } else {
1214 unsafe { std::env::remove_var(key) };
1216 }
1217 }
1218}