main
rs 333 lines 10.8 KB
Raw
1 //! Local HuggingFace model cache scanning and catalog merging.
2 //!
3 //! Checks two locations and merges the results:
4 //! 1. Apple App Group container — `~/Library/Group Containers/
5 //! group.com.ondeinference.apps/models/hub` (macOS only)
6 //! 2. Standard HF cache — respects `HF_HUB_CACHE`, `HUGGINGFACE_HUB_CACHE`,
7 //! `HF_HOME`, then falls back to `~/.cache/huggingface/hub`
8 //!
9 //! Models found in both locations are reported once, with `AppGroup` winning.
10 //! `merge_models` joins the remote Onde Inference catalog with local scan results
11 //! so the TUI can show all supported models with their download status.
12
13 use smbcloud_gresiq_sdk::OndeModel;
14 use std::{
15 collections::{HashMap, HashSet},
16 path::PathBuf,
17 };
18
19 // ---------------------------------------------------------------------------
20 // Public types
21 // ---------------------------------------------------------------------------
22
23 /// A model directory found in a local HuggingFace cache.
24 #[derive(Debug, Clone)]
25 pub struct LocalModel {
26 /// Full `org/name` identifier, e.g. `bartowski/Qwen2.5-1.5B-Instruct-GGUF`
27 pub model_id: String,
28 /// Short name segment shown in the TUI list
29 pub display_name: String,
30 /// Human-readable on-disk size (e.g. `941 MB`)
31 pub size_display: String,
32 /// Which cache this model came from
33 pub source: CacheSource,
34 }
35
36 /// A model from the Onde Inference catalog, enriched with local download status.
37 #[derive(Debug, Clone)]
38 pub struct MergedModel {
39 /// GresIQ internal model ID (used for assignment). `None` for local-only models.
40 pub catalog_id: Option<String>,
41 /// HuggingFace repo ID, e.g. `bartowski/Qwen2.5-1.5B-Instruct-GGUF`
42 pub model_id: String,
43 /// Human-readable name (from catalog when available, parsed from repo ID otherwise)
44 pub display_name: String,
45 /// On-disk size if downloaded, expected size from catalog otherwise
46 pub size_display: String,
47 /// Whether the model is present in a local HF cache
48 pub downloaded: bool,
49 /// Which cache it was found in (`None` if not downloaded)
50 pub source: Option<CacheSource>,
51 /// Full catalog entry — `None` for local-only models not in the catalog
52 pub catalog_model: Option<OndeModel>,
53 }
54
55 #[derive(Debug, Clone, PartialEq)]
56 pub enum CacheSource {
57 /// Apple App Group — shared across all Onde Inference apps on this Mac
58 AppGroup,
59 /// Standard HuggingFace home cache (`~/.cache/huggingface/hub` or `$HF_HOME`)
60 HfCache,
61 }
62
63 impl CacheSource {
64 pub fn label(&self) -> &'static str {
65 match self {
66 CacheSource::AppGroup => "Onde",
67 CacheSource::HfCache => "HF Cache",
68 }
69 }
70 }
71
72 // ---------------------------------------------------------------------------
73 // Path resolution
74 // ---------------------------------------------------------------------------
75
76 /// Resolve `~/Library/Group Containers/group.com.ondeinference.apps/models/hub`
77 /// on macOS. Returns `None` on non-macOS or if the directory does not exist.
78 #[cfg(target_os = "macos")]
79 fn app_group_hub() -> Option<PathBuf> {
80 let p = dirs::home_dir()?
81 .join("Library")
82 .join("Group Containers")
83 .join("group.com.ondeinference.apps")
84 .join("models")
85 .join("hub");
86 p.is_dir().then_some(p)
87 }
88
89 #[cfg(not(target_os = "macos"))]
90 fn app_group_hub() -> Option<PathBuf> {
91 None
92 }
93
94 /// Resolve the standard HuggingFace hub cache directory.
95 ///
96 /// Priority order (mirrors `hf-hub` and `onde` crate behaviour):
97 /// 1. `HF_HUB_CACHE` / `HUGGINGFACE_HUB_CACHE` env vars
98 /// 2. `$HF_HOME/hub`
99 /// 3. `~/.cache/huggingface/hub`
100 fn hf_home_hub() -> Option<PathBuf> {
101 for var in ["HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"] {
102 if let Ok(val) = std::env::var(var) {
103 let p = PathBuf::from(val);
104 if p.is_dir() {
105 return Some(p);
106 }
107 }
108 }
109
110 if let Ok(hf_home) = std::env::var("HF_HOME") {
111 let p = PathBuf::from(hf_home).join("hub");
112 if p.is_dir() {
113 return Some(p);
114 }
115 }
116
117 dirs::home_dir()
118 .map(|h| h.join(".cache").join("huggingface").join("hub"))
119 .filter(|p| p.is_dir())
120 }
121
122 // ---------------------------------------------------------------------------
123 // Private helpers
124 // ---------------------------------------------------------------------------
125
126 fn dir_size(path: &PathBuf) -> u64 {
127 let mut total = 0u64;
128 if let Ok(entries) = std::fs::read_dir(path) {
129 for entry in entries.flatten() {
130 let p = entry.path();
131 if p.is_file() {
132 total += p.metadata().map(|m| m.len()).unwrap_or(0);
133 } else if p.is_dir() {
134 total += dir_size(&p);
135 }
136 }
137 }
138 total
139 }
140
141 fn format_size(bytes: u64) -> String {
142 const KB: u64 = 1_024;
143 const MB: u64 = 1_024 * KB;
144 const GB: u64 = 1_024 * MB;
145
146 if bytes >= GB {
147 format!("{:.1} GB", bytes as f64 / GB as f64)
148 } else if bytes >= MB {
149 format!("{:.0} MB", bytes as f64 / MB as f64)
150 } else if bytes >= KB {
151 format!("{:.0} KB", bytes as f64 / KB as f64)
152 } else {
153 format!("{} B", bytes)
154 }
155 }
156
157 /// Scan a single hub cache directory and return all `models--*` entries.
158 fn scan_dir(dir: &PathBuf, source: CacheSource) -> Vec<LocalModel> {
159 let entries = match std::fs::read_dir(dir) {
160 Ok(e) => e,
161 Err(_) => return Vec::new(),
162 };
163
164 let mut models = Vec::new();
165
166 for entry in entries.flatten() {
167 let path = entry.path();
168 if !path.is_dir() {
169 continue;
170 }
171
172 let dir_name = match entry.file_name().into_string() {
173 Ok(n) => n,
174 Err(_) => continue,
175 };
176
177 // HF hub stores models as "models--{org}--{name}"
178 if !dir_name.starts_with("models--") {
179 continue;
180 }
181
182 let remainder = &dir_name["models--".len()..];
183 let model_id = remainder.replace("--", "/");
184 let display_name = model_id
185 .split('/')
186 .next_back()
187 .unwrap_or(&model_id)
188 .to_string();
189
190 let size_bytes = dir_size(&path);
191
192 models.push(LocalModel {
193 model_id,
194 display_name,
195 size_display: format_size(size_bytes),
196 source: source.clone(),
197 });
198 }
199
200 models
201 }
202
203 // ---------------------------------------------------------------------------
204 // Public API
205 // ---------------------------------------------------------------------------
206
207 /// Merge the remote Onde Inference model catalog with local HF cache scan results.
208 ///
209 /// - Every catalog model is included; `downloaded` is set based on the local scan.
210 /// - Local models not present in the catalog are appended with `catalog_id: None`.
211 /// - The merge key is `local.model_id == catalog.hf_repo_id`.
212 /// - Sort order: downloaded first, then alphabetically by display name.
213 pub fn merge_models(catalog: &[OndeModel], local: Vec<LocalModel>) -> Vec<MergedModel> {
214 // Index local models by their HF repo ID for O(1) lookup.
215 let local_by_id: HashMap<&str, &LocalModel> =
216 local.iter().map(|m| (m.model_id.as_str(), m)).collect();
217
218 let mut merged = Vec::new();
219 let mut seen: HashSet<String> = HashSet::new();
220
221 // Walk the catalog first so every supported model appears in the list.
222 for cm in catalog {
223 let hf_id = cm.hf_repo_id.as_deref().unwrap_or(cm.id.as_str());
224 seen.insert(hf_id.to_string());
225
226 let local_match = local_by_id.get(hf_id);
227 let downloaded = local_match.is_some();
228 let source = local_match.map(|lm| lm.source.clone());
229
230 let size_display = if let Some(lm) = local_match {
231 lm.size_display.clone()
232 } else if let Some(bytes) = cm.approx_size_bytes {
233 format_size(bytes as u64)
234 } else {
235 "–".to_string()
236 };
237
238 let display_name = cm
239 .name
240 .as_deref()
241 .unwrap_or_else(|| hf_id.split('/').next_back().unwrap_or(hf_id))
242 .to_string();
243
244 merged.push(MergedModel {
245 catalog_id: Some(cm.id.clone()),
246 model_id: hf_id.to_string(),
247 display_name,
248 size_display,
249 downloaded,
250 source,
251 catalog_model: Some(cm.clone()),
252 });
253 }
254
255 // Append any locally downloaded models that are not in the catalog.
256 for lm in &local {
257 if !seen.contains(lm.model_id.as_str()) {
258 merged.push(MergedModel {
259 catalog_id: None,
260 model_id: lm.model_id.clone(),
261 display_name: lm.display_name.clone(),
262 size_display: lm.size_display.clone(),
263 downloaded: true,
264 source: Some(lm.source.clone()),
265 catalog_model: None,
266 });
267 }
268 }
269
270 // Downloaded models first, then alphabetically.
271 merged.sort_by(|a, b| {
272 b.downloaded.cmp(&a.downloaded).then(
273 a.display_name
274 .to_lowercase()
275 .cmp(&b.display_name.to_lowercase()),
276 )
277 });
278
279 merged
280 }
281
282 /// Scan all local HuggingFace caches and return every downloaded model.
283 ///
284 /// The App Group container is checked first. If a model appears in both
285 /// locations it is reported once with [`CacheSource::AppGroup`]. Results are
286 /// sorted by `model_id` for a stable, predictable order.
287 ///
288 /// This function is synchronous and does filesystem I/O. Call it from a
289 /// `tokio::task::spawn_blocking` context to avoid blocking the async runtime.
290 pub fn list_local_models() -> Vec<LocalModel> {
291 let mut models = Vec::new();
292 let mut seen: HashSet<String> = HashSet::new();
293
294 // 1. Apple App Group cache (shared across all Onde Inference apps)
295 if let Some(path) = app_group_hub() {
296 for m in scan_dir(&path, CacheSource::AppGroup) {
297 seen.insert(m.model_id.clone());
298 models.push(m);
299 }
300 }
301
302 // 2. Standard HF cache — skip anything already found in the App Group
303 if let Some(path) = hf_home_hub() {
304 for m in scan_dir(&path, CacheSource::HfCache) {
305 if !seen.contains(&m.model_id) {
306 models.push(m);
307 }
308 }
309 }
310
311 models.sort_by_key(|m| m.model_id.to_lowercase());
312 models
313 }
314
315 /// Returns the preferred hub directory for downloading new models.
316 ///
317 /// On macOS, prefers the App Group container so downloads are shared with
318 /// the Onde Inference apps. Falls back to the standard HF home cache, then
319 /// `~/.cache/huggingface/hub` as a last resort.
320 pub fn preferred_download_hub() -> std::path::PathBuf {
321 #[cfg(target_os = "macos")]
322 if let Some(p) = app_group_hub() {
323 return p;
324 }
325
326 if let Some(p) = hf_home_hub() {
327 return p;
328 }
329
330 dirs::home_dir()
331 .map(|h| h.join(".cache").join("huggingface").join("hub"))
332 .unwrap_or_else(|| std::path::PathBuf::from(".cache/huggingface/hub"))
333 }