Skip to main content

vllm_cpp/
hf.rs

1use std::collections::{BTreeSet, HashMap};
2use std::fmt;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use hf_hub::api::sync::ApiBuilder;
7use hf_hub::api::RepoInfo;
8use hf_hub::{Cache, Repo, RepoType};
9use serde_json::Value;
10
11use crate::HuggingFaceError;
12
13const CONFIG: &str = "config.json";
14const TOKENIZER: &str = "tokenizer.json";
15const TOKENIZER_CONFIG: &str = "tokenizer_config.json";
16const SAFETENSORS: &str = "model.safetensors";
17const SAFETENSORS_INDEX: &str = "model.safetensors.index.json";
18const DEFAULT_REVISION: &str = "main";
19const HF_TOKEN: &str = "HF_TOKEN";
20
21/// A synchronous Hugging Face model resolver.
22///
23/// Resolution is separate from [`crate::Engine::load`]. The resolver returns a
24/// standalone GGUF path or a sparse, runtime-complete Safetensors snapshot
25/// directory in the normal Hugging Face cache layout.
26#[derive(Clone)]
27pub struct HuggingFaceModel {
28    repo_id: String,
29    revision: String,
30    kind: ModelKind,
31    cache_dir: Option<PathBuf>,
32    token: Option<String>,
33    progress: bool,
34    offline: bool,
35}
36
37#[derive(Clone, Debug)]
38enum ModelKind {
39    Gguf { filename: String },
40    Safetensors,
41}
42
43impl fmt::Debug for HuggingFaceModel {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.debug_struct("HuggingFaceModel")
46            .field("repo_id", &self.repo_id)
47            .field("revision", &self.revision)
48            .field("kind", &self.kind)
49            .field("cache_dir", &self.cache_dir)
50            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
51            .field("progress", &self.progress)
52            .field("offline", &self.offline)
53            .finish()
54    }
55}
56
57impl HuggingFaceModel {
58    /// Selects one standalone GGUF file from the repository's `main` revision.
59    #[must_use]
60    pub fn gguf(repo_id: impl Into<String>, filename: impl Into<String>) -> Self {
61        Self {
62            repo_id: repo_id.into(),
63            revision: DEFAULT_REVISION.to_owned(),
64            kind: ModelKind::Gguf {
65                filename: filename.into(),
66            },
67            cache_dir: None,
68            token: None,
69            progress: false,
70            offline: false,
71        }
72    }
73
74    /// Selects a runtime-complete Safetensors directory from the repository's `main` revision.
75    #[must_use]
76    pub fn safetensors(repo_id: impl Into<String>) -> Self {
77        Self {
78            repo_id: repo_id.into(),
79            revision: DEFAULT_REVISION.to_owned(),
80            kind: ModelKind::Safetensors,
81            cache_dir: None,
82            token: None,
83            progress: false,
84            offline: false,
85        }
86    }
87
88    /// Overrides the repository revision with a branch, tag, or commit.
89    #[must_use]
90    pub fn revision(mut self, revision: impl Into<String>) -> Self {
91        self.revision = revision.into();
92        self
93    }
94
95    /// Overrides the Hugging Face Hub cache directory.
96    ///
97    /// The path is the Hub cache itself (for example, `~/.cache/huggingface/hub`),
98    /// not its parent.
99    #[must_use]
100    pub fn cache_dir(mut self, cache_dir: impl Into<PathBuf>) -> Self {
101        self.cache_dir = Some(cache_dir.into());
102        self
103    }
104
105    /// Sets the Hugging Face token for this resolver.
106    ///
107    /// An explicit token takes precedence over `HF_TOKEN` and the cached token.
108    #[must_use]
109    pub fn token(mut self, token: impl Into<String>) -> Self {
110        self.token = Some(token.into());
111        self
112    }
113
114    /// Enables or disables download progress bars. Progress is disabled by default.
115    #[must_use]
116    pub fn progress(mut self, progress: bool) -> Self {
117        self.progress = progress;
118        self
119    }
120
121    /// Enables or disables cache-only resolution. Offline mode never builds an API.
122    #[must_use]
123    pub fn offline(mut self, offline: bool) -> Self {
124        self.offline = offline;
125        self
126    }
127
128    /// Resolves the selected model into the normal Hugging Face cache.
129    pub fn resolve(&self) -> Result<PathBuf, HuggingFaceError> {
130        self.validate()?;
131        let cache = self.cache();
132        if self.offline {
133            return self.resolve_offline(&cache);
134        }
135
136        match &self.kind {
137            ModelKind::Gguf { filename } => self.resolve_gguf_online(cache, filename),
138            ModelKind::Safetensors => self.resolve_safetensors_online(cache),
139        }
140    }
141
142    fn validate(&self) -> Result<(), HuggingFaceError> {
143        validate_nonempty("repository ID", &self.repo_id)?;
144        validate_nonempty("revision", &self.revision)?;
145        validate_repo_id(&self.repo_id)?;
146        validate_revision(&self.revision)?;
147
148        if let Some(token) = &self.token {
149            validate_nonempty("token", token)?;
150        }
151        if let ModelKind::Gguf { filename } = &self.kind {
152            validate_root_filename(filename, "GGUF filename")?;
153            if !filename.ends_with(".gguf") {
154                return Err(invalid("GGUF filename must end with lowercase `.gguf`"));
155            }
156            if is_split_gguf(filename) {
157                return Err(invalid("split GGUF sets are not supported"));
158            }
159        }
160        Ok(())
161    }
162
163    fn cache(&self) -> Cache {
164        self.cache_dir
165            .clone()
166            .map(Cache::new)
167            .unwrap_or_else(Cache::from_env)
168    }
169
170    fn api_builder(&self, cache: Cache) -> ApiBuilder {
171        let builder = ApiBuilder::from_cache(cache).with_progress(self.progress);
172        match self.selected_token(std::env::var(HF_TOKEN).ok()) {
173            Some(token) => builder.with_token(Some(token)),
174            None => builder,
175        }
176    }
177
178    fn selected_token(&self, environment_token: Option<String>) -> Option<String> {
179        self.token
180            .clone()
181            .or_else(|| environment_token.filter(|token| !token.trim().is_empty()))
182    }
183
184    fn requested_repo(&self) -> Repo {
185        Repo::with_revision(self.repo_id.clone(), RepoType::Model, self.revision.clone())
186    }
187
188    fn resolve_gguf_online(
189        &self,
190        cache: Cache,
191        filename: &str,
192    ) -> Result<PathBuf, HuggingFaceError> {
193        let api = self
194            .api_builder(cache)
195            .build()
196            .map_err(|error| hub(format!("could not create API: {error}")))?;
197        let path = api
198            .repo(self.requested_repo())
199            .get(filename)
200            .map_err(|error| {
201                hub(format!(
202                    "could not resolve `{filename}` from `{}` at `{}`: {error}",
203                    self.repo_id, self.revision
204                ))
205            })?;
206        verify_gguf_path(&path, filename)?;
207        Ok(path)
208    }
209
210    fn resolve_safetensors_online(&self, cache: Cache) -> Result<PathBuf, HuggingFaceError> {
211        let api = self
212            .api_builder(cache.clone())
213            .build()
214            .map_err(|error| hub(format!("could not create API: {error}")))?;
215        let info = api.repo(self.requested_repo()).info().map_err(|error| {
216            hub(format!(
217                "could not read metadata for `{}` at `{}`: {error}",
218                self.repo_id, self.revision
219            ))
220        })?;
221        let sha = info.sha.trim();
222        if sha.is_empty() {
223            return Err(incomplete("repository metadata has an empty commit SHA"));
224        }
225        validate_root_filename(sha, "repository metadata SHA")
226            .map_err(|_| incomplete("repository metadata has an unsafe SHA"))?;
227
228        let available = sibling_names(&info);
229        let bootstrap = plan_safetensors(&available)?;
230        let pinned_repo =
231            Repo::with_revision(self.repo_id.clone(), RepoType::Model, sha.to_owned());
232        let pinned = api.repo(pinned_repo.clone());
233        let index = if bootstrap.indexed {
234            let path = pinned.get(SAFETENSORS_INDEX).map_err(|error| {
235                hub(format!(
236                    "could not resolve `{SAFETENSORS_INDEX}` for `{}` at `{sha}`: {error}",
237                    self.repo_id
238                ))
239            })?;
240            let bytes = fs::read(&path).map_err(|error| {
241                io_error(format!("could not read `{}`: {error}", path.display()))
242            })?;
243            Some((path, bytes))
244        } else {
245            None
246        };
247        let (plan, index_path) = match index {
248            Some((path, bytes)) => (plan_indexed_safetensors(&available, &bytes)?, Some(path)),
249            None => (bootstrap, None),
250        };
251
252        let mut paths = HashMap::new();
253        if let Some(path) = index_path {
254            paths.insert(SAFETENSORS_INDEX.to_owned(), path);
255        }
256        for filename in &plan.files {
257            if paths.contains_key(filename) {
258                continue;
259            }
260            let path = pinned.get(filename).map_err(|error| {
261                hub(format!(
262                    "could not resolve `{filename}` for `{}` at `{sha}`: {error}",
263                    self.repo_id
264                ))
265            })?;
266            paths.insert(filename.clone(), path);
267        }
268
269        let snapshot = verify_snapshot_paths(&paths, sha)?;
270        cache
271            .repo(self.requested_repo())
272            .create_ref(sha)
273            .map_err(|error| {
274                io_error(format!(
275                    "could not update cache ref `{}` for `{}`: {error}",
276                    self.revision, self.repo_id
277                ))
278            })?;
279        Ok(snapshot)
280    }
281
282    fn resolve_offline(&self, cache: &Cache) -> Result<PathBuf, HuggingFaceError> {
283        let repo = cache.repo(self.requested_repo());
284        match &self.kind {
285            ModelKind::Gguf { filename } => {
286                let path = repo.get(filename).ok_or_else(|| {
287                    cache_miss(format!(
288                        "`{filename}` for `{}` at `{}` is not cached",
289                        self.repo_id, self.revision
290                    ))
291                })?;
292                let snapshot = snapshot_for_cached_revision(cache, &self.requested_repo())?;
293                verify_gguf_path(&path, filename)?;
294                if path.parent() != Some(snapshot.as_path()) {
295                    return Err(incomplete(format!(
296                        "resolved `{filename}` does not belong to the requested revision snapshot"
297                    )));
298                }
299                Ok(path)
300            }
301            ModelKind::Safetensors => {
302                let snapshot = snapshot_for_cached_revision(cache, &self.requested_repo())?;
303                let available = cached_root_files(&snapshot)?;
304                let bootstrap = plan_safetensors(&available)?;
305                let plan = if bootstrap.indexed {
306                    let index_path = snapshot.join(SAFETENSORS_INDEX);
307                    let bytes = fs::read(&index_path).map_err(|error| {
308                        io_error(format!(
309                            "could not read `{}`: {error}",
310                            index_path.display()
311                        ))
312                    })?;
313                    plan_indexed_safetensors(&available, &bytes)?
314                } else {
315                    bootstrap
316                };
317
318                let paths = plan
319                    .files
320                    .iter()
321                    .map(|filename| (filename.clone(), snapshot.join(filename)))
322                    .collect::<HashMap<_, _>>();
323                verify_snapshot_paths(&paths, snapshot_sha(&snapshot)?)
324            }
325        }
326    }
327}
328
329#[derive(Debug, Eq, PartialEq)]
330struct SafetensorsPlan {
331    files: Vec<String>,
332    indexed: bool,
333}
334
335fn plan_safetensors(available: &BTreeSet<String>) -> Result<SafetensorsPlan, HuggingFaceError> {
336    for required in [CONFIG, TOKENIZER] {
337        if !available.contains(required) {
338            return Err(incomplete(format!("required `{required}` is missing")));
339        }
340    }
341
342    let mut files = vec![CONFIG.to_owned(), TOKENIZER.to_owned()];
343    if available.contains(TOKENIZER_CONFIG) {
344        files.push(TOKENIZER_CONFIG.to_owned());
345    }
346    if available.contains(SAFETENSORS) {
347        files.push(SAFETENSORS.to_owned());
348        Ok(SafetensorsPlan {
349            files,
350            indexed: false,
351        })
352    } else if available.contains(SAFETENSORS_INDEX) {
353        files.push(SAFETENSORS_INDEX.to_owned());
354        Ok(SafetensorsPlan {
355            files,
356            indexed: true,
357        })
358    } else {
359        Err(incomplete(format!(
360            "neither `{SAFETENSORS}` nor `{SAFETENSORS_INDEX}` is present"
361        )))
362    }
363}
364
365fn plan_indexed_safetensors(
366    available: &BTreeSet<String>,
367    bytes: &[u8],
368) -> Result<SafetensorsPlan, HuggingFaceError> {
369    let mut plan = plan_safetensors(available)?;
370    if !plan.indexed {
371        return Ok(plan);
372    }
373
374    let value: Value = serde_json::from_slice(bytes)
375        .map_err(|error| incomplete(format!("`{SAFETENSORS_INDEX}` is malformed JSON: {error}")))?;
376    let weight_map = value
377        .get("weight_map")
378        .and_then(Value::as_object)
379        .ok_or_else(|| incomplete(format!("`{SAFETENSORS_INDEX}` has no object `weight_map`")))?;
380    if weight_map.is_empty() {
381        return Err(incomplete(format!(
382            "`{SAFETENSORS_INDEX}` has an empty `weight_map`"
383        )));
384    }
385
386    let mut shards = BTreeSet::new();
387    for value in weight_map.values() {
388        let shard = value.as_str().ok_or_else(|| {
389            incomplete(format!(
390                "`{SAFETENSORS_INDEX}` contains a non-string shard path"
391            ))
392        })?;
393        validate_root_filename(shard, "Safetensors shard")
394            .map_err(|error| incomplete(error.to_string()))?;
395        if !shard.ends_with(".safetensors") {
396            return Err(incomplete(format!(
397                "indexed shard `{shard}` must end with `.safetensors`"
398            )));
399        }
400        if !available.contains(shard) {
401            return Err(incomplete(format!(
402                "indexed shard `{shard}` is missing from repository metadata or cache"
403            )));
404        }
405        shards.insert(shard.to_owned());
406    }
407    plan.files.extend(shards);
408    Ok(plan)
409}
410
411fn sibling_names(info: &RepoInfo) -> BTreeSet<String> {
412    info.siblings
413        .iter()
414        .filter(|sibling| {
415            validate_root_filename(&sibling.rfilename, "repository metadata filename").is_ok()
416        })
417        .map(|sibling| sibling.rfilename.clone())
418        .collect()
419}
420
421fn cached_root_files(snapshot: &Path) -> Result<BTreeSet<String>, HuggingFaceError> {
422    let entries = fs::read_dir(snapshot).map_err(|error| {
423        io_error(format!(
424            "could not read cached snapshot `{}`: {error}",
425            snapshot.display()
426        ))
427    })?;
428    let mut files = BTreeSet::new();
429    for entry in entries {
430        let entry = entry.map_err(|error| {
431            io_error(format!(
432                "could not inspect cached snapshot `{}`: {error}",
433                snapshot.display()
434            ))
435        })?;
436        if entry.path().is_file() {
437            if let Some(filename) = entry.file_name().to_str() {
438                files.insert(filename.to_owned());
439            }
440        }
441    }
442    Ok(files)
443}
444
445fn snapshot_for_cached_revision(cache: &Cache, repo: &Repo) -> Result<PathBuf, HuggingFaceError> {
446    let cache_repo = cache.repo(repo.clone());
447    let ref_path = cache
448        .path()
449        .join(repo.folder_name())
450        .join("refs")
451        .join(repo.revision());
452    let sha = fs::read_to_string(&ref_path).map_err(|error| {
453        if error.kind() == std::io::ErrorKind::NotFound {
454            cache_miss(format!(
455                "revision `{}` for `{}` has no cache ref",
456                repo.revision(),
457                repo.folder_name()
458            ))
459        } else {
460            io_error(format!(
461                "could not read cache ref `{}`: {error}",
462                ref_path.display()
463            ))
464        }
465    })?;
466    let sha = sha.trim();
467    if sha.is_empty() {
468        return Err(incomplete(format!(
469            "cache ref `{}` has an empty SHA",
470            ref_path.display()
471        )));
472    }
473    validate_root_filename(sha, "cached revision SHA")
474        .map_err(|_| incomplete("cached revision has an unsafe SHA"))?;
475    let snapshot = cache_repo.pointer_path(sha);
476    if !snapshot.is_dir() {
477        return Err(cache_miss(format!(
478            "snapshot `{}` is not cached",
479            snapshot.display()
480        )));
481    }
482    Ok(snapshot)
483}
484
485fn verify_gguf_path(path: &Path, filename: &str) -> Result<(), HuggingFaceError> {
486    if !path.is_file() {
487        return Err(incomplete(format!(
488            "resolved `{filename}` is not a file at `{}`",
489            path.display()
490        )));
491    }
492    if path.file_name().and_then(|name| name.to_str()) != Some(filename) {
493        return Err(incomplete(format!(
494            "resolved GGUF path does not match requested filename `{filename}`"
495        )));
496    }
497    let snapshot = path
498        .parent()
499        .ok_or_else(|| incomplete(format!("resolved `{filename}` has no snapshot directory")))?;
500    if snapshot
501        .parent()
502        .and_then(Path::file_name)
503        .and_then(|name| name.to_str())
504        != Some("snapshots")
505    {
506        return Err(incomplete(format!(
507            "resolved `{filename}` is not directly under a `snapshots` directory"
508        )));
509    }
510    Ok(())
511}
512
513fn snapshot_sha(snapshot: &Path) -> Result<&str, HuggingFaceError> {
514    snapshot
515        .file_name()
516        .and_then(|value| value.to_str())
517        .ok_or_else(|| incomplete("cached snapshot has a non-UTF-8 SHA"))
518}
519
520fn verify_snapshot_paths(
521    paths: &HashMap<String, PathBuf>,
522    sha: &str,
523) -> Result<PathBuf, HuggingFaceError> {
524    if paths.is_empty() {
525        return Err(incomplete("no snapshot files were resolved"));
526    }
527    let mut common = None;
528    for (filename, path) in paths {
529        if !path.is_file() {
530            return Err(incomplete(format!(
531                "resolved `{filename}` is not a file at `{}`",
532                path.display()
533            )));
534        }
535        let parent = path.parent().ok_or_else(|| {
536            incomplete(format!("resolved `{filename}` has no snapshot directory"))
537        })?;
538        let valid_layout = parent.file_name().and_then(|name| name.to_str()) == Some(sha)
539            && parent
540                .parent()
541                .and_then(Path::file_name)
542                .and_then(|name| name.to_str())
543                == Some("snapshots");
544        if !valid_layout {
545            return Err(incomplete(format!(
546                "resolved `{filename}` is outside `snapshots/{sha}`"
547            )));
548        }
549        match &common {
550            Some(expected) if expected != parent => {
551                return Err(incomplete("resolved files belong to different snapshots"));
552            }
553            None => common = Some(parent.to_owned()),
554            _ => {}
555        }
556    }
557    common.ok_or_else(|| incomplete("no snapshot directory was resolved"))
558}
559
560fn validate_nonempty(field: &str, value: &str) -> Result<(), HuggingFaceError> {
561    if value.trim().is_empty() {
562        Err(invalid(format!("{field} must not be empty")))
563    } else {
564        Ok(())
565    }
566}
567
568fn validate_repo_id(repo_id: &str) -> Result<(), HuggingFaceError> {
569    validate_repo_relative_path(repo_id, "repository ID")?;
570    if repo_id.split('/').count() > 2 {
571        return Err(invalid("repository ID must be `name` or `namespace/name`"));
572    }
573    Ok(())
574}
575
576fn validate_revision(revision: &str) -> Result<(), HuggingFaceError> {
577    validate_repo_relative_path(revision, "revision")
578}
579
580fn validate_root_filename(filename: &str, field: &str) -> Result<(), HuggingFaceError> {
581    validate_repo_relative_path(filename, field)?;
582    if Path::new(filename).components().count() != 1 {
583        return Err(invalid(format!("{field} must be a root-level filename")));
584    }
585    Ok(())
586}
587
588fn validate_repo_relative_path(path: &str, field: &str) -> Result<(), HuggingFaceError> {
589    validate_nonempty(field, path)?;
590    if path.contains('\\')
591        || path.contains('\0')
592        || path.contains(':')
593        || path.starts_with('~')
594        || path.split('/').any(str::is_empty)
595    {
596        return Err(invalid(format!(
597            "{field} must use portable repository-relative path syntax"
598        )));
599    }
600    let path = Path::new(path);
601    if path.is_absolute()
602        || path
603            .components()
604            .any(|component| !matches!(component, Component::Normal(_)))
605    {
606        return Err(invalid(format!(
607            "{field} must not be absolute or contain `.` or `..` components"
608        )));
609    }
610    for component in path.components() {
611        let Component::Normal(component) = component else {
612            unreachable!("non-normal components were rejected above");
613        };
614        let component = component.to_string_lossy();
615        if component.ends_with([' ', '.'])
616            || component.chars().any(|value| {
617                value.is_control() || matches!(value, '<' | '>' | '"' | '|' | '?' | '*')
618            })
619        {
620            return Err(invalid(format!(
621                "{field} contains characters that are not portable path syntax"
622            )));
623        }
624    }
625    Ok(())
626}
627
628fn is_split_gguf(filename: &str) -> bool {
629    let stem = filename.strip_suffix(".gguf").unwrap_or(filename);
630    let Some((prefix, total)) = stem.rsplit_once("-of-") else {
631        return false;
632    };
633    let Some((_, part)) = prefix.rsplit_once('-') else {
634        return false;
635    };
636    !part.is_empty()
637        && !total.is_empty()
638        && part.bytes().all(|value| value.is_ascii_digit())
639        && total.bytes().all(|value| value.is_ascii_digit())
640}
641
642fn invalid(message: impl Into<String>) -> HuggingFaceError {
643    HuggingFaceError::InvalidInput {
644        message: message.into(),
645    }
646}
647
648fn cache_miss(message: impl Into<String>) -> HuggingFaceError {
649    HuggingFaceError::CacheMiss {
650        message: message.into(),
651    }
652}
653
654fn incomplete(message: impl Into<String>) -> HuggingFaceError {
655    HuggingFaceError::Incomplete {
656        message: message.into(),
657    }
658}
659
660fn hub(message: impl Into<String>) -> HuggingFaceError {
661    HuggingFaceError::Hub {
662        message: message.into(),
663    }
664}
665
666fn io_error(message: impl Into<String>) -> HuggingFaceError {
667    HuggingFaceError::Io {
668        message: message.into(),
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use hf_hub::api::Siblings;
676    use std::sync::atomic::{AtomicU64, Ordering};
677
678    static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
679    const REPO: &str = "owner/model";
680    const REVISION: &str = "release";
681    const SHA: &str = "0123456789abcdef";
682
683    struct TempDir(PathBuf);
684
685    impl TempDir {
686        fn new() -> Self {
687            let id = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
688            let path =
689                std::env::temp_dir().join(format!("vllm-cpp-hf-{}-{id}", std::process::id()));
690            fs::create_dir_all(&path).unwrap();
691            Self(path)
692        }
693    }
694
695    impl Drop for TempDir {
696        fn drop(&mut self) {
697            let _ = fs::remove_dir_all(&self.0);
698        }
699    }
700
701    fn names(values: &[&str]) -> BTreeSet<String> {
702        values.iter().map(|value| (*value).to_owned()).collect()
703    }
704
705    fn assert_incomplete_contains<T: fmt::Debug>(
706        result: Result<T, HuggingFaceError>,
707        expected: &str,
708    ) {
709        match result {
710            Err(HuggingFaceError::Incomplete { message }) => assert!(
711                message.contains(expected),
712                "expected `{message}` to contain `{expected}`"
713            ),
714            other => panic!("expected incomplete error containing `{expected}`, got {other:?}"),
715        }
716    }
717
718    fn cache_fixture_at(revision: &str, files: &[(&str, &[u8])]) -> TempDir {
719        let temp = TempDir::new();
720        let cache = Cache::new(temp.0.clone());
721        let repo = Repo::with_revision(REPO.to_owned(), RepoType::Model, revision.to_owned());
722        let cache_repo = cache.repo(repo);
723        cache_repo.create_ref(SHA).unwrap();
724        let snapshot = cache_repo.pointer_path(SHA);
725        fs::create_dir_all(&snapshot).unwrap();
726        for (filename, contents) in files {
727            fs::write(snapshot.join(filename), contents).unwrap();
728        }
729        temp
730    }
731
732    fn cache_fixture(files: &[(&str, &[u8])]) -> TempDir {
733        cache_fixture_at(DEFAULT_REVISION, files)
734    }
735
736    #[test]
737    fn validates_inputs_and_rejects_split_gguf() {
738        let cases = [
739            HuggingFaceModel::gguf("", "model.gguf"),
740            HuggingFaceModel::gguf(REPO, "model.gguf").revision(""),
741            HuggingFaceModel::gguf(REPO, "model.gguf").revision("../main"),
742            HuggingFaceModel::gguf("owner/model/extra", "model.gguf"),
743            HuggingFaceModel::gguf(REPO, "/model.gguf"),
744            HuggingFaceModel::gguf(REPO, "nested/model.gguf"),
745            HuggingFaceModel::gguf(REPO, "model.GGUF"),
746            HuggingFaceModel::gguf(REPO, "model-00001-of-00002.gguf"),
747            HuggingFaceModel::gguf(REPO, "model-1-of-2.gguf"),
748            HuggingFaceModel::gguf(REPO, "model?.gguf"),
749        ];
750        for model in cases {
751            assert!(matches!(
752                model.validate(),
753                Err(HuggingFaceError::InvalidInput { .. })
754            ));
755        }
756        assert!(HuggingFaceModel::gguf(REPO, "model.gguf")
757            .revision("refs/pr/1")
758            .validate()
759            .is_ok());
760    }
761
762    #[test]
763    fn defaults_to_main_and_accepts_revision_override() {
764        let default_gguf = HuggingFaceModel::gguf(REPO, "model.gguf");
765        let default_safetensors = HuggingFaceModel::safetensors(REPO);
766        assert_eq!(default_gguf.revision, DEFAULT_REVISION);
767        assert_eq!(default_safetensors.revision, DEFAULT_REVISION);
768
769        let pinned = default_safetensors.revision(REVISION);
770        assert_eq!(pinned.revision, REVISION);
771        assert_eq!(pinned.requested_repo().revision(), REVISION);
772    }
773
774    #[test]
775    fn explicit_token_precedes_environment_token() {
776        let explicit = HuggingFaceModel::safetensors(REPO).token("explicit");
777        assert_eq!(
778            explicit.selected_token(Some("environment".to_owned())),
779            Some("explicit".to_owned())
780        );
781
782        let environment = HuggingFaceModel::safetensors(REPO);
783        assert_eq!(
784            environment.selected_token(Some("environment".to_owned())),
785            Some("environment".to_owned())
786        );
787        assert_eq!(environment.selected_token(Some("  ".to_owned())), None);
788    }
789
790    #[test]
791    fn debug_redacts_explicit_token() {
792        let model = HuggingFaceModel::safetensors(REPO)
793            .revision(REVISION)
794            .token("hf_secret_value");
795        let debug = format!("{model:?}");
796        assert!(debug.contains("[REDACTED]"));
797        assert!(!debug.contains("hf_secret_value"));
798    }
799
800    #[test]
801    fn plans_unsharded_and_indexed_snapshots() {
802        let unsharded = names(&[CONFIG, TOKENIZER, TOKENIZER_CONFIG, SAFETENSORS]);
803        assert_eq!(
804            plan_safetensors(&unsharded).unwrap(),
805            SafetensorsPlan {
806                files: vec![CONFIG, TOKENIZER, TOKENIZER_CONFIG, SAFETENSORS]
807                    .into_iter()
808                    .map(str::to_owned)
809                    .collect(),
810                indexed: false,
811            }
812        );
813
814        let indexed = names(&[
815            CONFIG,
816            TOKENIZER,
817            SAFETENSORS_INDEX,
818            "model-00001-of-00002.safetensors",
819            "model-00002-of-00002.safetensors",
820        ]);
821        let bytes = br#"{"weight_map":{"a":"model-00002-of-00002.safetensors","b":"model-00001-of-00002.safetensors","c":"model-00002-of-00002.safetensors"}}"#;
822        let plan = plan_indexed_safetensors(&indexed, bytes).unwrap();
823        assert_eq!(
824            plan.files,
825            vec![
826                CONFIG,
827                TOKENIZER,
828                SAFETENSORS_INDEX,
829                "model-00001-of-00002.safetensors",
830                "model-00002-of-00002.safetensors",
831            ]
832        );
833    }
834
835    #[test]
836    fn rejects_incomplete_or_malformed_safetensors_metadata() {
837        let missing_core = names(&[CONFIG, SAFETENSORS]);
838        assert!(matches!(
839            plan_safetensors(&missing_core),
840            Err(HuggingFaceError::Incomplete { .. })
841        ));
842
843        let indexed = names(&[CONFIG, TOKENIZER, SAFETENSORS_INDEX, "part.safetensors"]);
844        for (bytes, expected) in [
845            (br#"not json"#.as_slice(), "malformed JSON"),
846            (br#"{}"#.as_slice(), "has no object `weight_map`"),
847            (br#"{"weight_map":{}}"#.as_slice(), "empty `weight_map`"),
848            (
849                br#"{"weight_map":{"a":3}}"#.as_slice(),
850                "non-string shard path",
851            ),
852            (
853                br#"{"weight_map":{"a":"../part.safetensors"}}"#.as_slice(),
854                "must not be absolute or contain `.` or `..` components",
855            ),
856            (
857                br#"{"weight_map":{"a":"/part.safetensors"}}"#.as_slice(),
858                "must use portable repository-relative path syntax",
859            ),
860            (
861                br#"{"weight_map":{"a":"nested/part.safetensors"}}"#.as_slice(),
862                "must be a root-level filename",
863            ),
864            (
865                br#"{"weight_map":{"a":"missing.safetensors"}}"#.as_slice(),
866                "indexed shard `missing.safetensors` is missing",
867            ),
868            (
869                br#"{"weight_map":{"a":"part.bin"}}"#.as_slice(),
870                "must end with `.safetensors`",
871            ),
872        ] {
873            assert_incomplete_contains(plan_indexed_safetensors(&indexed, bytes), expected);
874        }
875    }
876
877    #[test]
878    fn ignores_unrelated_unsafe_safetensors_siblings() {
879        let info = RepoInfo {
880            sha: SHA.to_owned(),
881            siblings: [
882                CONFIG,
883                TOKENIZER,
884                SAFETENSORS,
885                "../junk.json",
886                "/junk.json",
887                "nested/junk.json",
888            ]
889            .into_iter()
890            .map(|rfilename| Siblings {
891                rfilename: rfilename.to_owned(),
892            })
893            .collect(),
894        };
895
896        let available = sibling_names(&info);
897        assert_eq!(available, names(&[CONFIG, TOKENIZER, SAFETENSORS]));
898        assert!(plan_safetensors(&available).is_ok());
899
900        let unsafe_required = RepoInfo {
901            sha: SHA.to_owned(),
902            siblings: ["../config.json", TOKENIZER, SAFETENSORS]
903                .into_iter()
904                .map(|rfilename| Siblings {
905                    rfilename: rfilename.to_owned(),
906                })
907                .collect(),
908        };
909        assert_incomplete_contains(
910            plan_safetensors(&sibling_names(&unsafe_required)),
911            "required `config.json` is missing",
912        );
913    }
914
915    #[test]
916    fn resolves_complete_offline_unsharded_cache() {
917        let fixture = cache_fixture(&[
918            (CONFIG, b"{}"),
919            (TOKENIZER, b"{}"),
920            (TOKENIZER_CONFIG, b"{}"),
921            (SAFETENSORS, b"weights"),
922        ]);
923        let resolved = HuggingFaceModel::safetensors(REPO)
924            .cache_dir(&fixture.0)
925            .offline(true)
926            .resolve()
927            .unwrap();
928        assert_eq!(resolved.file_name().unwrap(), SHA);
929        assert_eq!(resolved.parent().unwrap().file_name().unwrap(), "snapshots");
930    }
931
932    #[test]
933    fn resolves_complete_offline_indexed_cache() {
934        let index = br#"{"weight_map":{"a":"model-00001-of-00002.safetensors","b":"model-00002-of-00002.safetensors"}}"#;
935        let fixture = cache_fixture_at(
936            REVISION,
937            &[
938                (CONFIG, b"{}"),
939                (TOKENIZER, b"{}"),
940                (SAFETENSORS_INDEX, index),
941                ("model-00001-of-00002.safetensors", b"one"),
942                ("model-00002-of-00002.safetensors", b"two"),
943            ],
944        );
945        assert!(HuggingFaceModel::safetensors(REPO)
946            .revision(REVISION)
947            .cache_dir(&fixture.0)
948            .offline(true)
949            .resolve()
950            .is_ok());
951    }
952
953    #[test]
954    fn distinguishes_offline_cache_miss_and_incomplete_snapshot() {
955        let empty = TempDir::new();
956        let miss = HuggingFaceModel::safetensors(REPO)
957            .cache_dir(&empty.0)
958            .offline(true)
959            .resolve();
960        assert!(matches!(miss, Err(HuggingFaceError::CacheMiss { .. })));
961
962        let partial = cache_fixture(&[(CONFIG, b"{}"), (TOKENIZER, b"{}")]);
963        let incomplete = HuggingFaceModel::safetensors(REPO)
964            .cache_dir(&partial.0)
965            .offline(true)
966            .resolve();
967        assert!(matches!(
968            incomplete,
969            Err(HuggingFaceError::Incomplete { .. })
970        ));
971
972        let index = br#"{"weight_map":{"a":"missing.safetensors"}}"#;
973        let missing_shard = cache_fixture(&[
974            (CONFIG, b"{}"),
975            (TOKENIZER, b"{}"),
976            (SAFETENSORS_INDEX, index),
977        ]);
978        assert_incomplete_contains(
979            HuggingFaceModel::safetensors(REPO)
980                .cache_dir(&missing_shard.0)
981                .offline(true)
982                .resolve(),
983            "indexed shard `missing.safetensors` is missing",
984        );
985    }
986
987    #[test]
988    fn resolves_offline_gguf_and_reports_missing_file() {
989        let fixture = cache_fixture(&[("model.gguf", b"gguf")]);
990        let resolved = HuggingFaceModel::gguf(REPO, "model.gguf")
991            .cache_dir(&fixture.0)
992            .offline(true)
993            .resolve()
994            .unwrap();
995        assert_eq!(resolved.file_name().unwrap(), "model.gguf");
996
997        let missing = HuggingFaceModel::gguf(REPO, "missing.gguf")
998            .cache_dir(&fixture.0)
999            .offline(true)
1000            .resolve();
1001        assert!(matches!(missing, Err(HuggingFaceError::CacheMiss { .. })));
1002    }
1003
1004    #[test]
1005    fn verifies_gguf_filename_and_snapshot_layout() {
1006        let fixture = cache_fixture(&[("model.gguf", b"gguf")]);
1007        let path = Cache::new(fixture.0.clone())
1008            .repo(Repo::with_revision(
1009                REPO.to_owned(),
1010                RepoType::Model,
1011                DEFAULT_REVISION.to_owned(),
1012            ))
1013            .get("model.gguf")
1014            .unwrap();
1015        assert!(verify_gguf_path(&path, "model.gguf").is_ok());
1016        assert_incomplete_contains(
1017            verify_gguf_path(&path, "other.gguf"),
1018            "does not match requested filename",
1019        );
1020
1021        let outside = fixture.0.join("outside.gguf");
1022        fs::write(&outside, b"gguf").unwrap();
1023        assert_incomplete_contains(
1024            verify_gguf_path(&outside, "outside.gguf"),
1025            "not directly under a `snapshots` directory",
1026        );
1027    }
1028
1029    #[test]
1030    fn rejects_mixed_snapshot_paths() {
1031        let temp = TempDir::new();
1032        let first = temp.0.join("snapshots").join(SHA);
1033        let second = temp.0.join("snapshots").join("other");
1034        fs::create_dir_all(&first).unwrap();
1035        fs::create_dir_all(&second).unwrap();
1036        fs::write(first.join(CONFIG), b"{}").unwrap();
1037        fs::write(second.join(TOKENIZER), b"{}").unwrap();
1038        let paths = HashMap::from([
1039            (CONFIG.to_owned(), first.join(CONFIG)),
1040            (TOKENIZER.to_owned(), second.join(TOKENIZER)),
1041        ]);
1042        assert!(matches!(
1043            verify_snapshot_paths(&paths, SHA),
1044            Err(HuggingFaceError::Incomplete { .. })
1045        ));
1046    }
1047}