kernel/discovery/
hf_scanner.rs1use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11
12use crate::discovery::gguf_models::is_mmproj_name;
13use crate::discovery::gguf_shards::{group, parse, shard_filename};
14use crate::discovery::modality_hints::{self, Hint};
15use crate::discovery::scanner::{DiscoveredModel, ScanResult, StoreScanner};
16use crate::records::{ExecutionMode, JsonValue, Modality, ModelSource, SourceKind};
17use crate::resolution::has_ggml_magic;
18
19const SENTENCE_TRANSFORMERS_MARKERS: [&str; 2] = ["config_sentence_transformers.json", "1_Pooling"];
22
23pub struct HFCacheScanner {
25 roots: Vec<PathBuf>,
26 user_roots: Vec<PathBuf>,
27}
28
29impl HFCacheScanner {
30 pub fn new(roots: Vec<PathBuf>) -> Self {
32 Self {
33 roots,
34 user_roots: Vec::new(),
35 }
36 }
37
38 pub fn single(root: impl Into<PathBuf>) -> Self {
40 Self::new(vec![root.into()])
41 }
42
43 pub fn with_user_roots(roots: Vec<PathBuf>, user_roots: Vec<PathBuf>) -> Self {
46 Self { roots, user_roots }
47 }
48
49 fn scan_root(&self, root: &Path, required: bool, result: &mut ScanResult) {
50 if !root.exists() {
51 if required {
52 mark_failed(result);
53 }
54 return;
55 }
56 let Ok(entries) = std::fs::read_dir(root) else {
57 mark_failed(result);
58 return;
59 };
60
61 for entry in entries.flatten() {
62 let dir = entry.path();
63 let Some(dir_name) = dir.file_name().and_then(|name| name.to_str()) else {
64 continue;
65 };
66 let Some(rest) = dir_name.strip_prefix("models--") else {
67 continue;
68 };
69 let repo = rest.replace("--", "/");
70
71 let Some((snapshot, revision)) = current_snapshot(&dir) else {
72 result
73 .issues
74 .push(format!("hf-cache: {repo} has no usable snapshot"));
75 continue;
76 };
77
78 let names = snapshot_file_names(&snapshot);
79 let mut diagnostics = Vec::new();
80 let hint = resolve_hint(&snapshot, &names, &mut diagnostics);
81
82 let downloading = has_incomplete_blobs(&dir.join("blobs"))
83 || index_references_missing_shard(&snapshot, &names)
84 || gguf_shards_incomplete(&snapshot, &names);
85
86 let name = repo
89 .rsplit('/')
90 .find(|segment| !segment.is_empty())
91 .unwrap_or(&repo)
92 .to_owned();
93 let mut source = ModelSource::new(SourceKind::huggingface_cache(), &display(&dir));
94 source.repo = Some(repo);
95 source.reference = Some(revision);
96
97 let mut discovered = DiscoveredModel::new(name, source);
98 discovered.modality_hint = hint.modality;
99 discovered.capabilities_hint = hint.capabilities;
100 discovered.execution_hint = hint.execution;
101 discovered.footprint_bytes = directory_bytes(&dir.join("blobs"));
102 discovered.primary_weight_path = largest_weight(&snapshot);
103 discovered.diagnostics = diagnostics;
104 discovered.context_length_hint = hint.context_length;
105 discovered.downloading = downloading;
106 result.discovered.push(discovered);
107 }
108 }
109}
110
111impl StoreScanner for HFCacheScanner {
112 fn kinds(&self) -> Vec<SourceKind> {
113 vec![SourceKind::huggingface_cache()]
114 }
115
116 fn scan(&self) -> ScanResult {
117 let mut result = ScanResult::default();
118 for root in &self.roots {
119 self.scan_root(root, false, &mut result);
120 }
121 for root in &self.user_roots {
122 self.scan_root(root, true, &mut result);
123 }
124 result
125 }
126}
127
128fn mark_failed(result: &mut ScanResult) {
129 let kind = SourceKind::huggingface_cache();
130 if !result.failed_kinds.contains(&kind) {
131 result.failed_kinds.push(kind);
132 }
133}
134
135fn current_snapshot(repo_dir: &Path) -> Option<(PathBuf, String)> {
138 let snapshots = repo_dir.join("snapshots");
139
140 if let Ok(revision) = std::fs::read_to_string(repo_dir.join("refs/main")) {
141 let trimmed = revision.trim();
142 if !trimmed.is_empty() {
143 let snapshot = snapshots.join(trimmed);
144 if snapshot.exists() {
145 return Some((snapshot, trimmed.to_owned()));
146 }
147 }
148 }
149
150 let mut newest: Option<(PathBuf, std::time::SystemTime)> = None;
151 for entry in std::fs::read_dir(&snapshots)
152 .into_iter()
153 .flatten()
154 .flatten()
155 {
156 if entry
157 .file_name()
158 .to_str()
159 .is_some_and(|name| name.starts_with('.'))
160 {
161 continue;
162 }
163 let modified = entry
164 .metadata()
165 .and_then(|meta| meta.modified())
166 .unwrap_or(std::time::UNIX_EPOCH);
167 if newest.as_ref().is_none_or(|(_, best)| modified > *best) {
169 newest = Some((entry.path(), modified));
170 }
171 }
172 newest.map(|(path, _)| {
173 let revision = path
174 .file_name()
175 .and_then(|name| name.to_str())
176 .unwrap_or_default()
177 .to_owned();
178 (path, revision)
179 })
180}
181
182fn snapshot_file_names(snapshot: &Path) -> BTreeSet<String> {
183 let mut names = BTreeSet::new();
184 for entry in std::fs::read_dir(snapshot).into_iter().flatten().flatten() {
185 if let Some(name) = entry.file_name().to_str()
186 && !name.starts_with('.')
187 {
188 names.insert(name.to_owned());
189 }
190 }
191 names
192}
193
194fn resolve_hint(snapshot: &Path, names: &BTreeSet<String>, diagnostics: &mut Vec<String>) -> Hint {
197 let mut hint = if names.contains("model_index.json") {
198 modality_hints::from_model_index(&snapshot.join("model_index.json"))
199 } else if names.contains("config.json") {
200 modality_hints::from_config_json(&snapshot.join("config.json"))
201 .unwrap_or_else(|| Hint::unknown(ExecutionMode::Sync))
202 } else if names.iter().any(|name| is_gguf(name)) {
203 modality_hints::gguf_hint()
204 } else {
205 diagnostics.push("no config.json or model_index.json in snapshot".to_owned());
206 Hint::unknown(ExecutionMode::Sync)
207 };
208
209 let text = Some(Modality::text());
210 if (hint.modality.is_none() || hint.modality == text)
211 && names
212 .iter()
213 .any(|name| SENTENCE_TRANSFORMERS_MARKERS.contains(&name.as_str()))
214 {
215 let mut embedding = modality_hints::embedding_hint();
216 embedding.context_length = hint.context_length;
217 hint = embedding;
218 }
219
220 if hint.modality == text
221 && !names
222 .iter()
223 .any(|name| name.starts_with("tokenizer") || name == "vocab.json")
224 {
225 diagnostics.push("no tokenizer found".to_owned());
226 }
227
228 hint
229}
230
231fn has_incomplete_blobs(blobs: &Path) -> bool {
232 for entry in std::fs::read_dir(blobs).into_iter().flatten().flatten() {
233 let is_incomplete = entry
234 .file_name()
235 .to_str()
236 .is_some_and(|name| name.ends_with(".incomplete"));
237 if is_incomplete
238 && entry
239 .file_type()
240 .map(|kind| kind.is_file())
241 .unwrap_or(false)
242 {
243 return true;
244 }
245 }
246 false
247}
248
249fn gguf_shards_incomplete(snapshot: &Path, names: &BTreeSet<String>) -> bool {
250 let ggufs: Vec<(PathBuf, i64)> = names
251 .iter()
252 .filter(|name| is_gguf(name))
253 .map(|name| (snapshot.join(name), 0))
254 .collect();
255 let (groups, _) = group(&ggufs);
256 groups.iter().any(|shard_group| !shard_group.complete())
257}
258
259fn index_references_missing_shard(snapshot: &Path, names: &BTreeSet<String>) -> bool {
263 for index_name in names
264 .iter()
265 .filter(|name| name.ends_with(".safetensors.index.json"))
266 {
267 let Ok(bytes) = std::fs::read(snapshot.join(index_name)) else {
268 continue;
269 };
270 let Ok(JsonValue::Object(json)) = serde_json::from_slice::<JsonValue>(&bytes) else {
271 continue;
272 };
273 let Some(JsonValue::Object(weight_map)) = json.get("weight_map") else {
274 continue;
275 };
276 let shards: BTreeSet<&str> = weight_map.values().filter_map(JsonValue::as_str).collect();
277 if shards.iter().any(|shard| !snapshot.join(shard).exists()) {
280 return true;
281 }
282 }
283 false
284}
285
286fn directory_bytes(dir: &Path) -> i64 {
287 let mut total = 0;
288 walk_bytes(dir, &mut total);
289 total
290}
291
292fn walk_bytes(dir: &Path, total: &mut i64) {
293 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
294 let path = entry.path();
295 match entry.file_type() {
296 Ok(kind) if kind.is_dir() => walk_bytes(&path, total),
297 _ => {
300 if let Ok(meta) = std::fs::metadata(&path)
301 && meta.is_file()
302 {
303 *total += meta.len() as i64;
304 }
305 }
306 }
307 }
308}
309
310fn largest_weight(snapshot: &Path) -> Option<String> {
313 let mut names = Vec::new();
314 let mut best: Option<(PathBuf, i64)> = None;
315 for entry in std::fs::read_dir(snapshot).into_iter().flatten().flatten() {
316 let path = entry.path();
317 if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
318 names.push(name.to_owned());
319 }
320 if is_weight_file(&path) {
321 let size = std::fs::metadata(&path)
322 .map(|meta| meta.len() as i64)
323 .unwrap_or(0);
324 if best.as_ref().is_none_or(|(_, best_size)| size > *best_size) {
325 best = Some((path, size));
326 }
327 }
328 }
329
330 let (best_path, _) = best?;
331 if let Some(shard) = best_path
332 .file_name()
333 .and_then(|name| name.to_str())
334 .and_then(parse)
335 {
336 let first = shard_filename(&shard.base, 1, shard.total);
337 if names.contains(&first) {
338 return Some(resolve(&snapshot.join(first)));
339 }
340 }
341 Some(resolve(&best_path))
342}
343
344fn is_weight_file(path: &Path) -> bool {
345 let name = path
346 .file_name()
347 .and_then(|name| name.to_str())
348 .unwrap_or_default();
349 if is_mmproj_name(name) {
350 return false;
351 }
352 match path
353 .extension()
354 .and_then(|ext| ext.to_str())
355 .map(str::to_ascii_lowercase)
356 .as_deref()
357 {
358 Some("safetensors" | "gguf") => true,
359 Some("bin") => has_ggml_magic(path),
360 _ => false,
361 }
362}
363
364fn is_gguf(name: &str) -> bool {
365 name.to_ascii_lowercase().ends_with(".gguf")
366}
367
368fn resolve(path: &Path) -> String {
370 std::fs::canonicalize(path)
371 .unwrap_or_else(|_| path.to_path_buf())
372 .to_string_lossy()
373 .into_owned()
374}
375
376fn display(path: &Path) -> String {
377 path.to_string_lossy().into_owned()
378}