Skip to main content

kernel/discovery/
hf_scanner.rs

1//! Scans a Hugging Face hub cache: each `models--<org>--<repo>` directory's
2//! current snapshot is inspected (`config.json` / `model_index.json` / a bare
3//! GGUF) for a modality hint, its blobs summed for the footprint, and its
4//! shard/blob completeness checked to flag a still-downloading model.
5//!
6//! The diffusers `model_index.json` path yields only a generic job hint until the
7//! pipeline-family registry it needs is ported.
8
9use 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
19/// Filenames that, alongside a text/unknown model, mark a sentence-transformers
20/// embedding model.
21const SENTENCE_TRANSFORMERS_MARKERS: [&str; 2] = ["config_sentence_transformers.json", "1_Pooling"];
22
23/// A scanner over one or more Hugging Face hub cache roots.
24pub struct HFCacheScanner {
25    roots: Vec<PathBuf>,
26    user_roots: Vec<PathBuf>,
27}
28
29impl HFCacheScanner {
30    /// A scanner over the given standard cache `roots` (a missing root is skipped).
31    pub fn new(roots: Vec<PathBuf>) -> Self {
32        Self {
33            roots,
34            user_roots: Vec::new(),
35        }
36    }
37
38    /// A scanner over a single root.
39    pub fn single(root: impl Into<PathBuf>) -> Self {
40        Self::new(vec![root.into()])
41    }
42
43    /// A scanner over standard `roots` (missing → skipped) plus `user_roots`
44    /// (missing → a scan failure, since the user pointed at them explicitly).
45    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            // Last non-empty path segment (empty segments are skipped, so a
87            // trailing slash doesn't yield an empty name).
88            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
135/// Pick the snapshot to represent a repo: `refs/main` if it points at a present
136/// snapshot, else the most-recently-modified snapshot directory.
137fn 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        // Strict `>` keeps the first of equal-mtime snapshots.
168        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
194/// Determine the modality hint for a snapshot from its files, then apply the
195/// sentence-transformers and missing-tokenizer refinements.
196fn 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
259/// Whether a `*.safetensors.index.json` weight map references a shard whose file
260/// (resolving symlinks into the blob store) is missing — the sign of a partial
261/// safetensors download.
262fn 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        // `exists()` follows the snapshot's symlink into `blobs/`, so a dangling
278        // link (a shard not yet downloaded) reads as missing.
279        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            // Follow symlinks for the size (a snapshot's weights are links into
298            // `blobs/`); count only what resolves to a regular file.
299            _ => {
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
310/// The largest weight file in a snapshot, resolved through its symlink. For a
311/// GGUF shard set, the first shard's path is returned instead.
312fn 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
368/// A path resolved through symlinks (falling back to itself), as a string.
369fn 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}