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