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};
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/// Filenames that, alongside a text/unknown model, mark a sentence-transformers
21/// embedding model.
22const SENTENCE_TRANSFORMERS_MARKERS: [&str; 2] = ["config_sentence_transformers.json", "1_Pooling"];
23
24/// A scanner over one or more Hugging Face hub cache roots.
25pub struct HFCacheScanner {
26    roots: Vec<PathBuf>,
27    user_roots: Vec<PathBuf>,
28}
29
30impl HFCacheScanner {
31    /// A scanner over the given standard cache `roots` (a missing root is skipped).
32    pub fn new(roots: Vec<PathBuf>) -> Self {
33        Self {
34            roots,
35            user_roots: Vec::new(),
36        }
37    }
38
39    /// A scanner over a single root.
40    pub fn single(root: impl Into<PathBuf>) -> Self {
41        Self::new(vec![root.into()])
42    }
43
44    /// A scanner over standard `roots` (missing → skipped) plus `user_roots`
45    /// (missing → a scan failure, since the user pointed at them explicitly).
46    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            // Walked once and shared: a repo that keeps a directory per
81            // quantization has no weights at the snapshot root, so the hint,
82            // the completeness check and the primary weight all have to look
83            // below it.
84            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            // Last non-empty path segment (empty segments are skipped, so a
93            // trailing slash doesn't yield an empty name).
94            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
141/// Pick the snapshot to represent a repo: `refs/main` if it points at a present
142/// snapshot, else the most-recently-modified snapshot directory.
143fn 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        // Strict `>` keeps the first of equal-mtime snapshots.
174        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
200/// Determine the modality hint for a snapshot from its files, then apply the
201/// sentence-transformers and missing-tokenizer refinements.
202fn 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    // Sizes are irrelevant to completeness, which counts members against the
262    // total each name declares.
263    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
268/// Whether a `*.safetensors.index.json` weight map references a shard whose file
269/// (resolving symlinks into the blob store) is missing — the sign of a partial
270/// safetensors download.
271fn 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        // `exists()` follows the snapshot's symlink into `blobs/`, so a dangling
287        // link (a shard not yet downloaded) reads as missing.
288        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            // Follow symlinks for the size (a snapshot's weights are links into
307            // `blobs/`); count only what resolves to a regular file.
308            _ => {
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
319/// The weight file a runtime would load, resolved through its symlink: the
320/// largest weight in the snapshot, where a GGUF is chosen by the rule
321/// identification reads the header by, and every other format is measured at
322/// the snapshot root, which is where those formats put their weights.
323///
324/// A shard set is weighed by its largest member but answers with its first
325/// file, which is what a server loads the rest through.
326fn 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
339/// The largest safetensors or GGML weight at the snapshot root, with a tie
340/// going to the earlier path so one snapshot always reads the same way.
341fn 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
368/// Whether `path` is a weight file in a format other than GGUF, which
369/// [`primary_of`] answers for.
370fn 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
390/// A path resolved through symlinks (falling back to itself), as a string.
391fn 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}