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;
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
20/// A scanner over one or more Hugging Face hub cache roots.
21pub struct HFCacheScanner {
22    roots: Vec<PathBuf>,
23    user_roots: Vec<PathBuf>,
24}
25
26impl HFCacheScanner {
27    /// A scanner over the given standard cache `roots` (a missing root is skipped).
28    pub fn new(roots: Vec<PathBuf>) -> Self {
29        Self {
30            roots,
31            user_roots: Vec::new(),
32        }
33    }
34
35    /// A scanner over a single root.
36    pub fn single(root: impl Into<PathBuf>) -> Self {
37        Self::new(vec![root.into()])
38    }
39
40    /// A scanner over standard `roots` (missing → skipped) plus `user_roots`
41    /// (missing → a scan failure, since the user pointed at them explicitly).
42    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            // Walked once and shared: a repo that keeps a directory per
77            // quantization has no weights at the snapshot root, so the hint,
78            // the completeness check and the primary weight all have to look
79            // below it.
80            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            // Last non-empty path segment (empty segments are skipped, so a
89            // trailing slash doesn't yield an empty name).
90            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
137/// Pick the snapshot to represent a repo: `refs/main` if it points at a present
138/// snapshot, else the most-recently-modified snapshot directory.
139fn 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        // Strict `>` keeps the first of equal-mtime snapshots.
170        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
196/// Determine the modality hint for a snapshot from its files, then apply the
197/// sentence-transformers and missing-tokenizer refinements.
198fn 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    // Sizes are irrelevant to completeness, which counts members against the
260    // total each name declares.
261    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
266/// Whether a `*.safetensors.index.json` weight map references a shard whose file
267/// (resolving symlinks into the blob store) is missing — the sign of a partial
268/// safetensors download.
269fn 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        // `exists()` follows the snapshot's symlink into `blobs/`, so a dangling
285        // link (a shard not yet downloaded) reads as missing.
286        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            // Follow symlinks for the size (a snapshot's weights are links into
305            // `blobs/`); count only what resolves to a regular file.
306            _ => {
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
317/// The weight file a runtime would load, resolved through its symlink: the
318/// largest weight in the snapshot, where a GGUF is chosen by the rule
319/// identification reads the header by, and every other format is measured at
320/// the snapshot root, which is where those formats put their weights.
321///
322/// A shard set is weighed by its largest member but answers with its first
323/// file, which is what a server loads the rest through.
324fn 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
337/// The largest safetensors or GGML weight at the snapshot root, with a tie
338/// going to the earlier path so one snapshot always reads the same way.
339fn 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
366/// Whether `path` is a weight file in a format other than GGUF, which
367/// [`primary_of`] answers for.
368fn 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
388/// A path resolved through symlinks (falling back to itself), as a string.
389fn 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}