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#[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 #[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 #[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 #[must_use]
90 pub fn revision(mut self, revision: impl Into<String>) -> Self {
91 self.revision = revision.into();
92 self
93 }
94
95 #[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 #[must_use]
109 pub fn token(mut self, token: impl Into<String>) -> Self {
110 self.token = Some(token.into());
111 self
112 }
113
114 #[must_use]
116 pub fn progress(mut self, progress: bool) -> Self {
117 self.progress = progress;
118 self
119 }
120
121 #[must_use]
123 pub fn offline(mut self, offline: bool) -> Self {
124 self.offline = offline;
125 self
126 }
127
128 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}