1 // Copyright 2026 Splitfire AB (Onde Inference). All rights reserved.
2 // SPDX-License-Identifier: MIT OR Apache-2.0
3
4 //! HuggingFace hub cache inspection, repair, and model download.
5 //!
6 //! This module owns all on-disk state management for locally cached
7 //! HuggingFace models and exposes a progress-callback-based download API
8 //! that is completely decoupled from any application framework.
9 //!
10 //! 1. Call [`list_local_hf_models`] / [`list_supported_hf_models`] /
11 //! [`delete_local_hf_model`] directly from your command handlers.
12 //! 2. Call [`download_model`] with an `Arc<dyn Fn(ModelDownloadProgress)>`
13 //! that forwards progress to whatever event system your app uses.
14
15 use {
16 crate::inference::models::{SUPPORTED_MODELS, SUPPORTED_MODEL_INFO},
17 log::{debug, info, warn},
18 serde::{Deserialize, Serialize},
19 std::{
20 fs,
21 path::{Path, PathBuf},
22 },
23 tsync::tsync,
24 };
25
26 // ---------------------------------------------------------------------------
27 // Public types
28 // ---------------------------------------------------------------------------
29
30 /// Information about a locally cached HuggingFace model.
31 #[tsync]
32 #[derive(Debug, Clone, Serialize, Deserialize)]
33 pub struct LocalHfModel {
34 /// The full model identifier, e.g. "bartowski/Qwen2.5-1.5B-Instruct-GGUF".
35 pub model_id: String,
36 /// The organisation or user that published the model.
37 pub org: String,
38 /// The model name without the org prefix.
39 pub name: String,
40 /// Total size on disk in bytes.
41 pub size_bytes: u64,
42 /// Human-readable size string, e.g. "4.2 GB".
43 pub size_display: String,
44 /// Absolute path to the model cache directory.
45 pub path: String,
46 /// List of snapshot revisions that are locally available.
47 pub revisions: Vec<String>,
48 }
49
50 /// Response returned by [`list_local_hf_models`].
51 #[tsync]
52 #[derive(Debug, Clone, Serialize, Deserialize)]
53 pub struct LocalHfModelsResponse {
54 /// The models found in the local HuggingFace cache.
55 pub models: Vec<LocalHfModel>,
56 /// The path that was scanned.
57 pub cache_path: String,
58 /// Total size of all cached models in bytes.
59 pub total_size_bytes: u64,
60 /// Human-readable total size string.
61 pub total_size_display: String,
62 }
63
64 /// A supported model that may or may not be downloaded locally.
65 #[tsync]
66 #[derive(Debug, Clone, Serialize, Deserialize)]
67 pub struct SupportedHfModel {
68 /// The full model identifier, e.g. "bartowski/Qwen2.5-1.5B-Instruct-GGUF".
69 pub model_id: String,
70 /// Human-friendly display name.
71 pub name: String,
72 /// Organisation or publisher display name.
73 pub org: String,
74 /// Short description of the model.
75 pub description: String,
76 /// Whether this model is fully downloaded locally.
77 pub is_downloaded: bool,
78 /// Whether a partial (incomplete) download exists on disk.
79 /// True when a cache directory exists but its size is less than 99 %
80 /// of `expected_size_bytes`.
81 pub is_incomplete: bool,
82 /// Bytes currently on disk for this model (0 when not present at all).
83 pub local_size_bytes: u64,
84 /// Human-readable local size string, e.g. "4.2 GB".
85 pub local_size_display: String,
86 /// Approximate total size in bytes when fully downloaded.
87 pub expected_size_bytes: u64,
88 /// Human-readable expected size string, e.g. "23.80 GB".
89 pub expected_size_display: String,
90 }
91
92 /// Response returned by [`list_supported_hf_models`].
93 #[tsync]
94 #[derive(Debug, Clone, Serialize, Deserialize)]
95 pub struct SupportedHfModelsResponse {
96 /// All models the app supports, with download status.
97 pub models: Vec<SupportedHfModel>,
98 }
99
100 /// Progress payload passed to the [`download_model`] callback on every poll
101 /// tick and on completion.
102 ///
103 /// Forward this value to your app's event system so the frontend can render
104 /// a progress bar.
105 #[tsync]
106 #[derive(Debug, Clone, Serialize, Deserialize)]
107 pub struct ModelDownloadProgress {
108 /// The model being downloaded.
109 pub model_id: String,
110 /// Bytes downloaded so far (observed from cache directory size).
111 pub downloaded_bytes: u64,
112 /// Human-readable downloaded size, e.g. "1.2 GB".
113 pub downloaded_display: String,
114 /// Expected total size in bytes (approximate).
115 pub total_bytes: u64,
116 /// Human-readable total size, e.g. "23.80 GB".
117 pub total_display: String,
118 /// Download progress as a value between 0.0 and 1.0.
119 pub progress: f64,
120 /// Whether the download has completed.
121 pub done: bool,
122 }
123
124 // ---------------------------------------------------------------------------
125 // Internal constants
126 // ---------------------------------------------------------------------------
127
128 /// Fraction of `expected_size_bytes` that must be present on disk for a
129 /// model to be considered fully downloaded (99 %).
130 const DOWNLOAD_COMPLETE_THRESHOLD: f64 = 0.99;
131
132 // ---------------------------------------------------------------------------
133 // Private helpers
134 // ---------------------------------------------------------------------------
135
136 /// Resolve the HuggingFace hub cache directory.
137 ///
138 /// Respects the `HF_HOME` / `HUGGINGFACE_HUB_CACHE` environment variables.
139 /// Falls back to `~/.cache/huggingface/hub`.
140 ///
141 /// On iOS the `HF_HOME` variable is set during app setup (see
142 /// `setup_application_filesystem`) to a path inside the app's writable
143 /// `Library/Application Support/` container. We create the directory if it
144 /// doesn't exist yet so that first-run and freshly-installed apps work
145 /// correctly.
146 fn hf_cache_dir() -> Option<PathBuf> {
147 // 1. Explicit hub cache override
148 if let Ok(cache) = std::env::var("HUGGINGFACE_HUB_CACHE") {
149 let p = PathBuf::from(cache);
150 let _ = fs::create_dir_all(&p);
151 if p.is_dir() {
152 return Some(p);
153 }
154 }
155
156 // 2. HF_HOME override (hub is a subdirectory)
157 if let Ok(hf_home) = std::env::var("HF_HOME") {
158 let p = PathBuf::from(hf_home).join("hub");
159 let _ = fs::create_dir_all(&p);
160 if p.is_dir() {
161 return Some(p);
162 }
163 }
164
165 // 3. Default: ~/.cache/huggingface/hub
166 if let Some(home) = home::home_dir() {
167 let p = home.join(".cache").join("huggingface").join("hub");
168 let _ = fs::create_dir_all(&p);
169 if p.is_dir() {
170 return Some(p);
171 }
172 }
173
174 None
175 }
176
177 /// Recursively compute the total size of a directory in bytes.
178 ///
179 /// Follows symlinks to count the real file size and also counts in-progress
180 /// `.incomplete` staging files so that progress polling reflects partial
181 /// downloads before they are renamed to their final blob name.
182 fn dir_size(path: &PathBuf) -> u64 {
183 let mut total: u64 = 0;
184 if let Ok(entries) = fs::read_dir(path) {
185 for entry in entries.flatten() {
186 let entry_path = entry.path();
187 if entry_path.is_dir() {
188 total += dir_size(&entry_path);
189 } else {
190 // Count both completed blobs AND in-progress staging files
191 // (hf-hub downloads to `<sha256>.incomplete` and renames on
192 // completion — without this, progress stays at 0% for the
193 // entire download and jumps to 100% at the end).
194 if let Ok(meta) = entry_path.symlink_metadata() {
195 if meta.is_symlink() {
196 // Follow the symlink to get the real file size.
197 if let Ok(real_meta) = entry_path.metadata() {
198 total += real_meta.len();
199 }
200 } else {
201 total += meta.len();
202 }
203 }
204 }
205 }
206 }
207 total
208 }
209
210 /// Format a byte count into a human-readable string.
211 fn format_size(bytes: u64) -> String {
212 const KB: u64 = 1024;
213 const MB: u64 = KB * 1024;
214 const GB: u64 = MB * 1024;
215 const TB: u64 = GB * 1024;
216
217 if bytes >= TB {
218 format!("{:.2} TB", bytes as f64 / TB as f64)
219 } else if bytes >= GB {
220 format!("{:.2} GB", bytes as f64 / GB as f64)
221 } else if bytes >= MB {
222 format!("{:.1} MB", bytes as f64 / MB as f64)
223 } else if bytes >= KB {
224 format!("{:.0} KB", bytes as f64 / KB as f64)
225 } else {
226 format!("{} B", bytes)
227 }
228 }
229
230 /// List the snapshot revisions available for a cached model directory.
231 fn list_revisions(model_dir: &Path) -> Vec<String> {
232 let snapshots_dir = model_dir.join("snapshots");
233 if !snapshots_dir.is_dir() {
234 return Vec::new();
235 }
236
237 let mut revisions = Vec::new();
238 if let Ok(entries) = fs::read_dir(&snapshots_dir) {
239 for entry in entries.flatten() {
240 if entry.path().is_dir() {
241 if let Some(name) = entry.file_name().to_str() {
242 revisions.push(name.to_string());
243 }
244 }
245 }
246 }
247 revisions.sort();
248 revisions
249 }
250
251 /// Build a [`ModelDownloadProgress`] payload from raw byte counts.
252 #[cfg(any(
253 target_os = "macos",
254 target_os = "ios",
255 target_os = "tvos",
256 target_os = "visionos",
257 target_os = "watchos",
258 target_os = "android",
259 target_os = "windows",
260 target_os = "linux"
261 ))]
262 fn make_progress(model_id: &str, downloaded: u64, total: u64, done: bool) -> ModelDownloadProgress {
263 let progress = if total > 0 {
264 (downloaded as f64 / total as f64).min(1.0)
265 } else {
266 0.0
267 };
268 ModelDownloadProgress {
269 model_id: model_id.to_string(),
270 downloaded_bytes: downloaded,
271 downloaded_display: format_size(downloaded),
272 total_bytes: total,
273 total_display: format_size(total),
274 progress,
275 done,
276 }
277 }
278
279 // ---------------------------------------------------------------------------
280 // Public cache utilities
281 // ---------------------------------------------------------------------------
282
283 /// Return the cache sub-directory path for a given model ID.
284 ///
285 /// E.g. for `"bartowski/Qwen2.5-1.5B-Instruct-GGUF"` the cache directory is
286 /// `<hf_cache>/models--bartowski--Qwen2.5-1.5B-Instruct-GGUF`.
287 pub fn model_cache_path(model_id: &str) -> Option<PathBuf> {
288 let cache_dir = hf_cache_dir()?;
289 let dir_name = format!("models--{}", model_id.replace('/', "--"));
290 Some(cache_dir.join(dir_name))
291 }
292
293 /// Pre-create the HuggingFace cache directory tree for `model_id` so that the
294 /// progress-monitor thread can find a valid path immediately, even before
295 /// hf-hub creates it during the first download.
296 ///
297 /// hf-hub won't be confused by a pre-existing (empty) directory — it will
298 /// simply populate it as normal.
299 pub fn ensure_model_cache_dir(model_id: &str) {
300 if let Some(model_path) = model_cache_path(model_id) {
301 let blobs_dir = model_path.join("blobs");
302 if let Err(e) = fs::create_dir_all(&blobs_dir) {
303 warn!(
304 "Could not pre-create model cache dir {}: {}",
305 blobs_dir.display(),
306 e
307 );
308 } else {
309 info!("Pre-created model cache dir: {}", model_path.display());
310 }
311 }
312 }
313
314 /// Remove stale `.lock` and orphaned `.part` files from the HF cache
315 /// `blobs/` directory for a model.
316 ///
317 /// The HF hub client creates `.lock` files during download. If a download is
318 /// interrupted these locks persist and prevent any subsequent access (download
319 /// **or** model load). Calling this before loading or resuming a download
320 /// allows the hub client to re-acquire the lock and proceed normally.
321 pub fn clean_stale_lock_files(model_id: &str) {
322 if let Some(cache_path) = model_cache_path(model_id) {
323 let blobs_dir = cache_path.join("blobs");
324 if blobs_dir.is_dir() {
325 // Collect the names of complete blobs (no extension) so we can
326 // decide whether a `.part` file is stale.
327 let complete_blobs: Vec<String> = fs::read_dir(&blobs_dir)
328 .into_iter()
329 .flatten()
330 .flatten()
331 .filter_map(|e| {
332 let p = e.path();
333 if p.is_file() && p.extension().is_none() {
334 p.file_name().map(|n| n.to_string_lossy().to_string())
335 } else {
336 None
337 }
338 })
339 .collect();
340
341 if let Ok(entries) = fs::read_dir(&blobs_dir) {
342 for entry in entries.flatten() {
343 let path = entry.path();
344 let ext = path.extension().and_then(|e| e.to_str());
345
346 match ext {
347 Some("lock") => {
348 info!("Removing stale lock file: {}", path.display());
349 if let Err(e) = fs::remove_file(&path) {
350 warn!("Failed to remove lock file {}: {}", path.display(), e);
351 }
352 }
353 Some("part") => {
354 // A `.part` file is a partial download left by hf-hub.
355 // If the corresponding complete blob exists (same name
356 // without `.part`), the partial file is stale and
357 // causes hf-hub to issue a spurious Range-resume
358 // download instead of using the complete blob.
359 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
360 if complete_blobs.iter().any(|b| b == stem) {
361 let part_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
362 info!(
363 "Removing stale .part file ({} bytes, complete blob exists): {}",
364 part_size,
365 path.display()
366 );
367 if let Err(e) = fs::remove_file(&path) {
368 warn!("Failed to remove .part file {}: {}", path.display(), e);
369 }
370 } else {
371 let part_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
372 debug!(
373 "Keeping .part file ({} bytes, no complete blob yet): {}",
374 part_size,
375 path.display()
376 );
377 }
378 }
379 _ => {}
380 }
381 }
382 }
383 }
384 }
385 }
386
387 /// Log the complete HF cache structure for a model to aid debugging.
388 ///
389 /// This is called before model load so the developer can see exactly what's
390 /// present in the cache, whether `refs/main` resolves, whether symlinks are
391 /// intact, and whether any `.part` files are left over from interrupted
392 /// downloads.
393 pub fn diagnose_hf_cache(model_id: &str) {
394 let cache_path = match model_cache_path(model_id) {
395 Some(p) => p,
396 None => {
397 info!("diagnose_hf_cache({}): cache path not found", model_id);
398 return;
399 }
400 };
401
402 if !cache_path.is_dir() {
403 info!(
404 "diagnose_hf_cache({}): directory does not exist: {}",
405 model_id,
406 cache_path.display()
407 );
408 return;
409 }
410
411 info!(
412 "diagnose_hf_cache({}): scanning {}",
413 model_id,
414 cache_path.display()
415 );
416
417 // ── refs/ ────────────────────────────────────────────────────────────
418 let refs_dir = cache_path.join("refs");
419 if refs_dir.is_dir() {
420 if let Ok(entries) = fs::read_dir(&refs_dir) {
421 for entry in entries.flatten() {
422 let name = entry.file_name().to_string_lossy().to_string();
423 match fs::read_to_string(entry.path()) {
424 Ok(content) => {
425 let hash = content.trim();
426 info!(
427 " refs/{} → \"{}\" (len={})",
428 name,
429 if hash.len() > 12 {
430 format!("{}…", &hash[..12])
431 } else {
432 hash.to_string()
433 },
434 hash.len()
435 );
436 }
437 Err(e) => {
438 warn!(" refs/{} → <unreadable: {}>", name, e);
439 }
440 }
441 }
442 }
443 } else {
444 warn!(" refs/ directory missing!");
445 }
446
447 // ── blobs/ ───────────────────────────────────────────────────────────
448 let blobs_dir = cache_path.join("blobs");
449 if blobs_dir.is_dir() {
450 if let Ok(entries) = fs::read_dir(&blobs_dir) {
451 for entry in entries.flatten() {
452 let path = entry.path();
453 let name = entry.file_name().to_string_lossy().to_string();
454 let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
455 let kind = if name.ends_with(".lock") {
456 "LOCK"
457 } else if name.ends_with(".part") {
458 "PART (incomplete)"
459 } else {
460 "blob"
461 };
462 info!(
463 " blobs/{} — {} ({} bytes / {:.1} MB)",
464 name,
465 kind,
466 size,
467 size as f64 / 1_048_576.0
468 );
469 }
470 }
471 } else {
472 warn!(" blobs/ directory missing!");
473 }
474
475 // ── snapshots/ ───────────────────────────────────────────────────────
476 let snapshots_dir = cache_path.join("snapshots");
477 if snapshots_dir.is_dir() {
478 if let Ok(revs) = fs::read_dir(&snapshots_dir) {
479 for rev_entry in revs.flatten() {
480 let rev_name = rev_entry.file_name().to_string_lossy().to_string();
481 let rev_display = if rev_name.len() > 12 {
482 format!("{}…", &rev_name[..12])
483 } else {
484 rev_name.clone()
485 };
486 info!(" snapshots/{}/", rev_display);
487
488 if let Ok(files) = fs::read_dir(rev_entry.path()) {
489 for file_entry in files.flatten() {
490 let file_name = file_entry.file_name().to_string_lossy().to_string();
491 let file_path = file_entry.path();
492
493 if file_path.is_symlink() {
494 let target = fs::read_link(&file_path)
495 .map(|t| t.to_string_lossy().to_string())
496 .unwrap_or_else(|_| "<unreadable>".to_string());
497 let accessible = fs::metadata(&file_path).is_ok();
498 let status = if accessible { "OK" } else { "BROKEN" };
499 info!(" {} → {} [symlink: {}]", file_name, target, status);
500 } else if file_path.is_file() {
501 let size = fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
502 info!(
503 " {} [{} bytes / {:.1} MB, real file]",
504 file_name,
505 size,
506 size as f64 / 1_048_576.0
507 );
508 } else {
509 warn!(" {} [not a file or symlink!]", file_name);
510 }
511 }
512 }
513 }
514 }
515 } else {
516 warn!(" snapshots/ directory missing!");
517 }
518
519 info!("diagnose_hf_cache({}): done", model_id);
520 }
521
522 /// Repair broken symlinks in the HF hub cache for a given model.
523 ///
524 /// The `hf-hub` crate stores downloaded files as blobs keyed by SHA-256 and
525 /// creates **symlinks** from `snapshots/<rev>/<filename>` → `../../blobs/<hash>`.
526 /// When the cache directory is copied into an iOS app container (e.g. via
527 /// Xcode "Replace Container" or `ideviceinstaller`), symlinks are often
528 /// silently dropped or broken. The `hf-hub` client then thinks the file is
529 /// missing and re-downloads it — even though the 940 MB blob is right there.
530 ///
531 /// This function walks every snapshot entry for `model_id`. If a path is a
532 /// broken symlink **or** a missing file, and a matching blob exists, it
533 /// replaces the symlink with a hard copy of the blob so that `hf-hub`'s
534 /// `Cache::get()` finds a real file at the expected path.
535 ///
536 /// It also ensures a `refs/main` file exists pointing at the snapshot
537 /// revision, since that file is needed for `hf-hub` to resolve the "main"
538 /// ref to a concrete snapshot directory.
539 ///
540 /// This is idempotent and safe to call on every app launch.
541 pub fn repair_hf_cache_symlinks(model_id: &str) {
542 let cache_path = match model_cache_path(model_id) {
543 Some(p) if p.is_dir() => p,
544 _ => return,
545 };
546
547 let blobs_dir = cache_path.join("blobs");
548 let snapshots_dir = cache_path.join("snapshots");
549 let refs_dir = cache_path.join("refs");
550
551 if !blobs_dir.is_dir() || !snapshots_dir.is_dir() {
552 return;
553 }
554
555 // Collect blob filenames (excluding .lock files) for lookup.
556 let blob_names: Vec<String> = match fs::read_dir(&blobs_dir) {
557 Ok(entries) => entries
558 .flatten()
559 .filter_map(|e| {
560 let name = e.file_name().to_string_lossy().to_string();
561 if name.ends_with(".lock") {
562 None
563 } else if e.path().is_file() {
564 Some(name)
565 } else {
566 None
567 }
568 })
569 .collect(),
570 Err(_) => return,
571 };
572
573 if blob_names.is_empty() {
574 return;
575 }
576
577 // Walk each snapshot revision directory.
578 let snapshot_revs: Vec<(String, PathBuf)> = match fs::read_dir(&snapshots_dir) {
579 Ok(entries) => entries
580 .flatten()
581 .filter(|e| e.path().is_dir())
582 .map(|e| {
583 let rev = e.file_name().to_string_lossy().to_string();
584 (rev, e.path())
585 })
586 .collect(),
587 Err(_) => return,
588 };
589
590 for (rev, snap_dir) in &snapshot_revs {
591 let entries = match fs::read_dir(snap_dir) {
592 Ok(e) => e,
593 Err(_) => continue,
594 };
595
596 for entry in entries.flatten() {
597 let snap_file = entry.path();
598 let file_name = entry.file_name().to_string_lossy().to_string();
599
600 // Check if the file is accessible (broken symlink → metadata fails).
601 let needs_repair = if snap_file.is_symlink() {
602 // Symlink exists but may be broken — check if target is readable.
603 fs::metadata(&snap_file).is_err()
604 } else if snap_file.exists() {
605 // Real file, no repair needed.
606 false
607 } else {
608 // Path doesn't exist at all (dangling entry in readdir).
609 true
610 };
611
612 if !needs_repair {
613 continue;
614 }
615
616 // Try to find a matching blob. For GGUF repos there's usually
617 // just one blob; for multi-file repos we match by the symlink
618 // target name if we can read it, otherwise fall back to the
619 // single-blob heuristic.
620 let blob_name = if let Ok(target) = fs::read_link(&snap_file) {
621 target
622 .file_name()
623 .map(|n| n.to_string_lossy().to_string())
624 .filter(|n| blob_names.contains(n))
625 } else {
626 None
627 };
628
629 let resolved_blob = if let Some(ref name) = blob_name {
630 Some(blobs_dir.join(name))
631 } else if blob_names.len() == 1 {
632 // Single-blob repo (typical for GGUF) — safe to assume it
633 // corresponds to this snapshot entry.
634 Some(blobs_dir.join(&blob_names[0]))
635 } else {
636 None
637 };
638
639 if let Some(blob_path) = resolved_blob {
640 if !blob_path.is_file() {
641 continue;
642 }
643
644 // Remove the broken symlink before copying.
645 if snap_file.is_symlink() || snap_file.exists() {
646 let _ = fs::remove_file(&snap_file);
647 }
648
649 info!(
650 "repair_hf_cache_symlinks: replacing broken symlink with copy: {} → {}",
651 blob_path.display(),
652 snap_file.display()
653 );
654
655 // Prefer hard link (instant, no extra disk space) over copy.
656 if fs::hard_link(&blob_path, &snap_file).is_err() {
657 if let Err(e) = fs::copy(&blob_path, &snap_file) {
658 warn!(
659 "repair_hf_cache_symlinks: failed to copy blob to snapshot: {}",
660 e
661 );
662 }
663 }
664 } else {
665 warn!(
666 "repair_hf_cache_symlinks: broken symlink for '{}' but no matching blob found",
667 file_name
668 );
669 }
670 }
671
672 // Ensure refs/main exists and points to this revision so that
673 // `hf-hub` can resolve the snapshot.
674 if !refs_dir.join("main").is_file() {
675 let _ = fs::create_dir_all(&refs_dir);
676 if let Err(e) = fs::write(refs_dir.join("main"), rev.as_bytes()) {
677 warn!("repair_hf_cache_symlinks: failed to write refs/main: {}", e);
678 } else {
679 info!("repair_hf_cache_symlinks: created refs/main → {}", rev);
680 }
681 }
682 }
683 }
684
685 /// Ensure snapshot entries exist for known GGUF files.
686 ///
687 /// When a download completes but the process is killed before hf-hub creates
688 /// the snapshot symlink, the blob exists but hf-hub considers it a cache miss
689 /// and re-downloads. This function creates hard links from the snapshot
690 /// directory to the blob directory for any missing entries.
691 pub fn ensure_snapshot_entries(model_id: &str, expected_files: &[&str]) {
692 let cache_path = match model_cache_path(model_id) {
693 Some(p) if p.is_dir() => p,
694 _ => return,
695 };
696
697 let blobs_dir = cache_path.join("blobs");
698 let snapshots_dir = cache_path.join("snapshots");
699 let refs_dir = cache_path.join("refs");
700
701 if !blobs_dir.is_dir() {
702 return;
703 }
704
705 // Collect non-lock, non-part blob files.
706 let blob_entries: Vec<(String, u64)> = match fs::read_dir(&blobs_dir) {
707 Ok(entries) => entries
708 .flatten()
709 .filter_map(|e| {
710 let name = e.file_name().to_string_lossy().to_string();
711 if name.ends_with(".lock") || name.ends_with(".part") {
712 return None;
713 }
714 let meta = e.metadata().ok()?;
715 if meta.is_file() && meta.len() > 0 {
716 Some((name, meta.len()))
717 } else {
718 None
719 }
720 })
721 .collect(),
722 Err(_) => return,
723 };
724
725 if blob_entries.is_empty() {
726 return;
727 }
728
729 // Find the revision from refs/main, or from an existing snapshot dir.
730 let revision = fs::read_to_string(refs_dir.join("main"))
731 .ok()
732 .map(|s| s.trim().to_string())
733 .or_else(|| {
734 fs::read_dir(&snapshots_dir)
735 .ok()?
736 .flatten()
737 .find(|e| e.path().is_dir())
738 .map(|e| e.file_name().to_string_lossy().to_string())
739 });
740
741 let revision = match revision {
742 Some(r) if !r.is_empty() => r,
743 _ => return,
744 };
745
746 let snap_dir = snapshots_dir.join(&revision);
747 let _ = fs::create_dir_all(&snap_dir);
748
749 for expected_file in expected_files {
750 let snap_file = snap_dir.join(expected_file);
751 if snap_file.exists() {
752 continue;
753 }
754
755 // For single-blob repos, link the only blob. For multi-blob repos,
756 // we can't determine the mapping without metadata.
757 let blob_to_link = if blob_entries.len() == 1 {
758 Some(&blob_entries[0].0)
759 } else {
760 None
761 };
762
763 if let Some(blob_name) = blob_to_link {
764 let blob_path = blobs_dir.join(blob_name);
765 if fs::hard_link(&blob_path, &snap_file).is_ok() {
766 info!(
767 "ensure_snapshot_entries: hard-linked {} → {}",
768 expected_file, blob_name
769 );
770 } else if fs::copy(&blob_path, &snap_file).is_ok() {
771 info!(
772 "ensure_snapshot_entries: copied {} → {}",
773 expected_file, blob_name
774 );
775 } else {
776 warn!(
777 "ensure_snapshot_entries: failed to link/copy blob for {}",
778 expected_file
779 );
780 }
781 }
782 }
783 }
784
785 // ---------------------------------------------------------------------------
786 // Query / mutation functions
787 // ---------------------------------------------------------------------------
788
789 /// Scan the local HuggingFace hub cache and return all downloaded models that
790 /// the inference engine supports.
791 ///
792 /// The cache directory is typically `~/.cache/huggingface/hub/` and contains
793 /// sub-directories named `models--{org}--{model}` for each cached model.
794 pub fn list_local_hf_models() -> LocalHfModelsResponse {
795 let cache_dir = match hf_cache_dir() {
796 Some(dir) => dir,
797 None => {
798 debug!("HuggingFace cache directory not found.");
799 return LocalHfModelsResponse {
800 models: Vec::new(),
801 cache_path: String::new(),
802 total_size_bytes: 0,
803 total_size_display: "0 B".to_string(),
804 };
805 }
806 };
807
808 let cache_path_str = cache_dir.to_string_lossy().to_string();
809 debug!("Scanning HuggingFace cache at: {}", cache_path_str);
810
811 let mut models: Vec<LocalHfModel> = Vec::new();
812
813 let entries = match fs::read_dir(&cache_dir) {
814 Ok(entries) => entries,
815 Err(e) => {
816 warn!("Failed to read HuggingFace cache directory: {:?}", e);
817 return LocalHfModelsResponse {
818 models: Vec::new(),
819 cache_path: cache_path_str,
820 total_size_bytes: 0,
821 total_size_display: "0 B".to_string(),
822 };
823 }
824 };
825
826 for entry in entries.flatten() {
827 let entry_path = entry.path();
828 if !entry_path.is_dir() {
829 continue;
830 }
831
832 let dir_name = match entry.file_name().to_str() {
833 Some(name) => name.to_string(),
834 None => continue,
835 };
836
837 // HuggingFace hub caches model repos as "models--{org}--{name}"
838 if !dir_name.starts_with("models--") {
839 continue;
840 }
841
842 // Strip the "models--" prefix and split by "--" to get org/name
843 let remainder = &dir_name["models--".len()..];
844 let parts: Vec<&str> = remainder.splitn(2, "--").collect();
845 if parts.len() != 2 {
846 // Some models have no org (single segment), handle gracefully
847 let model_id = remainder.replace("--", "/");
848 let size = dir_size(&entry_path);
849 let revisions = list_revisions(&entry_path);
850
851 models.push(LocalHfModel {
852 model_id: model_id.clone(),
853 org: String::new(),
854 name: model_id,
855 size_bytes: size,
856 size_display: format_size(size),
857 path: entry_path.to_string_lossy().to_string(),
858 revisions,
859 });
860 continue;
861 }
862
863 let org = parts[0].to_string();
864 let name = parts[1].to_string();
865 let model_id = format!("{}/{}", org, name);
866 let size = dir_size(&entry_path);
867 let revisions = list_revisions(&entry_path);
868
869 models.push(LocalHfModel {
870 model_id,
871 org,
872 name,
873 size_bytes: size,
874 size_display: format_size(size),
875 path: entry_path.to_string_lossy().to_string(),
876 revisions,
877 });
878 }
879
880 // Only keep models that the inference engine actually supports.
881 models.retain(|m| SUPPORTED_MODELS.iter().any(|&s| s == m.model_id));
882
883 // Sort by model_id for a stable, predictable order.
884 models.sort_by_key(|a| a.model_id.to_lowercase());
885
886 let total_size_bytes: u64 = models.iter().map(|m| m.size_bytes).sum();
887
888 debug!(
889 "Found {} local HuggingFace model(s), total size: {}",
890 models.len(),
891 format_size(total_size_bytes)
892 );
893
894 LocalHfModelsResponse {
895 models,
896 cache_path: cache_path_str,
897 total_size_bytes,
898 total_size_display: format_size(total_size_bytes),
899 }
900 }
901
902 /// Delete a locally cached HuggingFace model by removing its directory from
903 /// the hub cache.
904 ///
905 /// `model_id` should be the full identifier, e.g. "bartowski/Qwen2.5-1.5B-Instruct-GGUF".
906 /// Returns `Ok(())` on success or an error string describing what went wrong.
907 pub fn delete_local_hf_model(model_id: String) -> Result<(), String> {
908 let cache_dir = hf_cache_dir().ok_or("HuggingFace cache directory not found.".to_string())?;
909
910 // Convert model_id (e.g. "org/name") to the cache directory name ("models--org--name")
911 let dir_name = format!("models--{}", model_id.replace('/', "--"));
912 let model_path = cache_dir.join(&dir_name);
913
914 if !model_path.exists() {
915 return Err(format!(
916 "Model cache directory not found: {}",
917 model_path.display()
918 ));
919 }
920
921 if !model_path.is_dir() {
922 return Err(format!(
923 "Expected a directory but found a file: {}",
924 model_path.display()
925 ));
926 }
927
928 debug!(
929 "Deleting local HuggingFace model: {} at {:?}",
930 model_id, model_path
931 );
932
933 fs::remove_dir_all(&model_path).map_err(|e| {
934 let msg = format!("Failed to delete model {}: {}", model_id, e);
935 warn!("{}", msg);
936 msg
937 })?;
938
939 debug!("Successfully deleted local model: {}", model_id);
940 Ok(())
941 }
942
943 /// Return the full list of models the inference engine supports, together with
944 /// flags indicating whether each one is fully downloaded, partially downloaded,
945 /// or not present at all.
946 pub fn list_supported_hf_models() -> SupportedHfModelsResponse {
947 let models = SUPPORTED_MODEL_INFO
948 .iter()
949 .map(|info| {
950 let cache_path = model_cache_path(info.id);
951 let local_size = cache_path
952 .as_ref()
953 .filter(|p| p.exists())
954 .map(dir_size)
955 .unwrap_or(0);
956
957 let has_cache = local_size > 0;
958 let is_complete = info.expected_size_bytes > 0
959 && local_size as f64
960 >= info.expected_size_bytes as f64 * DOWNLOAD_COMPLETE_THRESHOLD;
961
962 SupportedHfModel {
963 model_id: info.id.to_string(),
964 name: info.name.to_string(),
965 org: info.org.to_string(),
966 description: info.description.to_string(),
967 is_downloaded: has_cache && is_complete,
968 is_incomplete: has_cache && !is_complete,
969 local_size_bytes: local_size,
970 local_size_display: format_size(local_size),
971 expected_size_bytes: info.expected_size_bytes,
972 expected_size_display: format_size(info.expected_size_bytes),
973 }
974 })
975 .collect();
976
977 SupportedHfModelsResponse { models }
978 }
979
980 // ---------------------------------------------------------------------------
981 // Model download — platform-specific implementations
982 // ---------------------------------------------------------------------------
983 //
984 // The download function accepts a generic progress callback so that callers
985 // can forward progress to any event system without coupling to this crate.
986 //
987 // Callback signature: `Fn(ModelDownloadProgress) + Send + Sync + Clone + 'static`
988 // - Called every ~2 s from a native monitor thread.
989 // - Called once with `done: true` after the build completes.
990
991 // ── Apple (macOS + iOS): Metal-accelerated, all model types ─────────────────
992
993 #[cfg(any(
994 target_os = "macos",
995 target_os = "ios",
996 target_os = "tvos",
997 target_os = "visionos",
998 target_os = "watchos",
999 target_os = "windows",
1000 target_os = "linux"
1001 ))]
1002 pub async fn download_model<F>(
1003 model_id: String,
1004 on_progress: F,
1005 app_data_dir: Option<PathBuf>,
1006 ) -> Result<(), String>
1007 where
1008 F: Fn(ModelDownloadProgress) + Send + Sync + Clone + 'static,
1009 {
1010 use crate::inference::token::hf_token_source;
1011 use crate::inference::GgufModelConfig;
1012 use mistralrs::GgufModelBuilder;
1013 use std::sync::{
1014 atomic::{AtomicBool, Ordering},
1015 Arc,
1016 };
1017 use std::time::Duration;
1018
1019 // When app_data_dir is provided, seed the HF env vars so that hf-hub,
1020 // mistral.rs, and hf_cache_dir() all write inside the app's writable
1021 // container. This is required on every sandboxed platform:
1022 // - iOS: ~/.cache is outside the container (os error 1).
1023 // - macOS App Store: the app is sandboxed, ~/.cache is inaccessible.
1024 // It is harmless on non-sandboxed desktop builds (the cache simply
1025 // lives under the app data dir instead of ~/.cache).
1026 if let Some(ref data_dir) = app_data_dir {
1027 let hf_home = data_dir.join("models");
1028 let hf_hub_cache = hf_home.join("hub");
1029 fs::create_dir_all(&hf_hub_cache)
1030 .map_err(|e| format!("Cannot create HF cache dir: {e}"))?;
1031 std::env::set_var("HF_HUB_CACHE", &hf_hub_cache);
1032 std::env::set_var("HF_HOME", &hf_home);
1033 info!(
1034 "HF hub cache resolved to app data path: {}",
1035 hf_hub_cache.display()
1036 );
1037 }
1038
1039 if !SUPPORTED_MODELS.contains(&model_id.as_str()) {
1040 return Err(format!("Model {} is not supported.", model_id));
1041 }
1042
1043 let config = GgufModelConfig::from_supported_model_id(&model_id)
1044 .ok_or_else(|| format!("Model {model_id} has no download configuration."))?;
1045
1046 let expected_size = SUPPORTED_MODEL_INFO
1047 .iter()
1048 .find(|m| m.id == model_id)
1049 .map(|m| m.expected_size_bytes)
1050 .unwrap_or(0);
1051
1052 info!(
1053 "Starting download for model: {} (expected ~{})",
1054 model_id,
1055 format_size(expected_size)
1056 );
1057
1058 clean_stale_lock_files(&model_id);
1059 repair_hf_cache_symlinks(&model_id);
1060 ensure_model_cache_dir(&model_id);
1061
1062 // Recover from interrupted downloads where the blob exists but the
1063 // snapshot entry was never created (process killed after download,
1064 // before hf-hub finalised the cache structure).
1065 let expected_files: Vec<&str> = config.files.iter().map(String::as_str).collect();
1066 ensure_snapshot_entries(&model_id, &expected_files);
1067
1068 // Emit an initial progress event based on the actual cache size so that
1069 // resumed downloads don't visually restart from 0%.
1070 let initial_bytes = model_cache_path(&model_id)
1071 .filter(|p| p.exists())
1072 .map(|p| dir_size(&p))
1073 .unwrap_or(0);
1074 on_progress(make_progress(
1075 &model_id,
1076 initial_bytes,
1077 expected_size,
1078 false,
1079 ));
1080
1081 let mut builder =
1082 GgufModelBuilder::new(&model_id, config.files.clone()).with_token_source(hf_token_source());
1083 if let Some(chat_template) = config.chat_template {
1084 builder = builder.with_chat_template(chat_template);
1085 }
1086
1087 // Shared flag so the monitor task knows when to stop.
1088 let finished = Arc::new(AtomicBool::new(false));
1089 let finished_clone = Arc::clone(&finished);
1090 let monitor_model_id = model_id.clone();
1091 let monitor_cb = on_progress.clone();
1092
1093 // Spawn a native OS thread to poll the cache directory size every 2 seconds.
1094 // We use std::thread instead of tokio::spawn because the model builder's
1095 // .build().await internally calls the synchronous hf-hub API (api.get()),
1096 // which blocks the tokio worker thread. This starves any tokio-spawned
1097 // monitor tasks, preventing progress updates. A native thread runs
1098 // independently of the tokio runtime and is immune to this starvation.
1099 let monitor_handle = std::thread::spawn(move || loop {
1100 std::thread::sleep(Duration::from_secs(2));
1101
1102 if finished_clone.load(Ordering::Relaxed) {
1103 break;
1104 }
1105
1106 let current_bytes = model_cache_path(&monitor_model_id)
1107 .filter(|p| p.exists())
1108 .map(|p| dir_size(&p))
1109 .unwrap_or(0);
1110
1111 monitor_cb(make_progress(
1112 &monitor_model_id,
1113 current_bytes,
1114 expected_size,
1115 false,
1116 ));
1117 });
1118
1119 // Building triggers the HuggingFace download and also validates that the
1120 // downloaded GGUF can be opened by the same backend used for inference.
1121 let result = builder.build().await;
1122
1123 finished.store(true, Ordering::Relaxed);
1124 if monitor_handle.join().is_err() {
1125 warn!("Download progress monitor panicked for model {model_id}");
1126 }
1127
1128 match result {
1129 Ok(_model) => {
1130 let final_bytes = model_cache_path(&model_id)
1131 .filter(|p| p.exists())
1132 .map(|p| dir_size(&p))
1133 .unwrap_or(expected_size);
1134 on_progress(make_progress(&model_id, final_bytes, final_bytes, true));
1135 info!("Successfully downloaded model: {}", model_id);
1136 Ok(())
1137 }
1138 Err(e) => {
1139 let msg = format!("Failed to download model {}: {}", model_id, e);
1140 warn!("{}", msg);
1141 Err(msg)
1142 }
1143 }
1144 }
1145
1146 // ── Android: GGUF-only download ───────────────────────────────────────────
1147 //
1148 // On Android there is no Metal GPU, so only pre-quantized GGUF models
1149 // (CPU inference via candle) are supported. We mirror the Apple
1150 // implementation but restrict to GGUF presets and skip all Metal / ISQ
1151 // model builders.
1152 //
1153 // `app_data_dir` is **required** on Android: `dirs::home_dir()` returns
1154 // `None` inside the Android sandbox, which causes `hf_hub::Cache::default()`
1155 // to panic. The caller resolves the app data directory and passes it here.
1156
1157 #[cfg(target_os = "android")]
1158 pub async fn download_model<F>(
1159 model_id: String,
1160 on_progress: F,
1161 app_data_dir: Option<PathBuf>,
1162 ) -> Result<(), String>
1163 where
1164 F: Fn(ModelDownloadProgress) + Send + Sync + Clone + 'static,
1165 {
1166 use crate::inference::token::hf_token_source;
1167 use crate::inference::GgufModelConfig;
1168 use hf_hub::Cache;
1169 use mistralrs::GgufModelBuilder;
1170 use mistralrs_core::GLOBAL_HF_CACHE;
1171 use std::sync::{
1172 atomic::{AtomicBool, Ordering},
1173 Arc,
1174 };
1175 use std::time::Duration;
1176
1177 let config = GgufModelConfig::from_supported_model_id(&model_id)
1178 .ok_or_else(|| format!("Model {model_id} has no Android download configuration."))?;
1179 let tok_model_id = config
1180 .tok_model_id
1181 .clone()
1182 .ok_or_else(|| format!("Model {model_id} has no Android tokenizer configuration."))?;
1183
1184 // ── Resolve the HF cache path ─────────────────────────────────────────
1185 //
1186 // `app_data_dir` is required on Android — `dirs::home_dir()` returns None
1187 // in the Android sandbox and causes Cache::default() to panic.
1188 let resolved_app_data = app_data_dir
1189 .ok_or_else(|| "app_data_dir is required on Android for HF cache resolution".to_string())?;
1190
1191 let hf_home = resolved_app_data.join("models");
1192 let hf_hub_cache = hf_home.join("hub");
1193
1194 fs::create_dir_all(&hf_hub_cache)
1195 .map_err(|e| format!("Failed to create HF hub cache dir: {e}"))?;
1196
1197 // Seed GLOBAL_HF_CACHE so get_paths_gguf! never falls back to Cache::default().
1198 GLOBAL_HF_CACHE.get_or_init(|| Cache::new(hf_hub_cache.clone()));
1199
1200 // Keep env vars in sync so hf_hub_cache_dir() and hf_cache_dir() agree.
1201 std::env::set_var("HF_HUB_CACHE", &hf_hub_cache);
1202 std::env::set_var("HF_HOME", &hf_home);
1203
1204 info!(
1205 "Android HF hub cache resolved to: {}",
1206 hf_hub_cache.display()
1207 );
1208
1209 let expected_size = SUPPORTED_MODEL_INFO
1210 .iter()
1211 .find(|m| m.id == model_id)
1212 .map(|m| m.expected_size_bytes)
1213 .unwrap_or(0);
1214
1215 info!(
1216 "Starting Android download for model: {} (expected ~{})",
1217 model_id,
1218 format_size(expected_size)
1219 );
1220
1221 clean_stale_lock_files(&model_id);
1222 let expected_files: Vec<&str> = config.files.iter().map(String::as_str).collect();
1223 ensure_snapshot_entries(&model_id, &expected_files);
1224 ensure_model_cache_dir(&model_id);
1225
1226 let initial_bytes = model_cache_path(&model_id)
1227 .filter(|p| p.exists())
1228 .map(|p| dir_size(&p))
1229 .unwrap_or(0);
1230 on_progress(make_progress(
1231 &model_id,
1232 initial_bytes,
1233 expected_size,
1234 false,
1235 ));
1236
1237 let mut builder = GgufModelBuilder::new(&model_id, config.files.clone())
1238 .with_tok_model_id(&tok_model_id)
1239 .with_token_source(hf_token_source())
1240 .with_logging();
1241 if let Some(chat_template) = config.chat_template {
1242 builder = builder.with_chat_template(chat_template);
1243 }
1244
1245 // Native monitor thread — same reasoning as the Apple implementation.
1246 let finished = Arc::new(AtomicBool::new(false));
1247 let finished_clone = Arc::clone(&finished);
1248 let monitor_model_id = model_id.clone();
1249 let monitor_cb = on_progress.clone();
1250
1251 let monitor_handle = std::thread::spawn(move || loop {
1252 std::thread::sleep(Duration::from_secs(2));
1253 if finished_clone.load(Ordering::Relaxed) {
1254 break;
1255 }
1256 let current_bytes = model_cache_path(&monitor_model_id)
1257 .filter(|p| p.exists())
1258 .map(|p| dir_size(&p))
1259 .unwrap_or(0);
1260 monitor_cb(make_progress(
1261 &monitor_model_id,
1262 current_bytes,
1263 expected_size,
1264 false,
1265 ));
1266 });
1267
1268 let result = builder.build().await;
1269
1270 finished.store(true, Ordering::Relaxed);
1271 if monitor_handle.join().is_err() {
1272 warn!("Download progress monitor panicked for model {model_id}");
1273 }
1274
1275 match result {
1276 Ok(_model) => {
1277 let final_bytes = model_cache_path(&model_id)
1278 .filter(|p| p.exists())
1279 .map(|p| dir_size(&p))
1280 .unwrap_or(expected_size);
1281 on_progress(make_progress(&model_id, final_bytes, final_bytes, true));
1282 info!("Successfully downloaded model on Android: {}", model_id);
1283 Ok(())
1284 }
1285 Err(e) => {
1286 let msg = format!("Failed to download model {}: {}", model_id, e);
1287 warn!("{}", msg);
1288 Err(msg)
1289 }
1290 }
1291 }
1292
1293 // ── Other platforms: no-op ────────────────────────────────────────────────
1294
1295 #[cfg(not(any(
1296 target_os = "macos",
1297 target_os = "ios",
1298 target_os = "tvos",
1299 target_os = "visionos",
1300 target_os = "watchos",
1301 target_os = "android",
1302 target_os = "windows",
1303 target_os = "linux"
1304 )))]
1305 pub async fn download_model<F>(
1306 model_id: String,
1307 _on_progress: F,
1308 _app_data_dir: Option<PathBuf>,
1309 ) -> Result<(), String>
1310 where
1311 F: Fn(ModelDownloadProgress) + Send + Sync + Clone + 'static,
1312 {
1313 debug!(
1314 "download_model: not supported on this platform, ignoring request for {}.",
1315 model_id
1316 );
1317 Err(format!(
1318 "Model downloads are not supported on this platform (requested: {}).",
1319 model_id
1320 ))
1321 }