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