@tuned-tensor/cli 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -97,6 +97,8 @@ Node owns input validation, state, process lifecycle, manifests, and reports.
97
97
  The bundled Python project owns four small operations:
98
98
 
99
99
  - `prefetch.py`: download and verify the pinned Hugging Face snapshot;
100
+ - `prefetch_dataset.py`: download and verify the spec's Hugging Face dataset
101
+ files (`dataset_prebuilt.huggingface`);
100
102
  - `evaluate.py`: generate predictions without receiving reference answers;
101
103
  - `train.py`: perform CUDA-only, model-specific LoRA SFT;
102
104
  - `serve.py`: prepare the verified base model and adapter for the vLLM serving runtime.
@@ -125,6 +127,19 @@ Canonical JSON and JSONL files are the state store; there is no database or
125
127
  mirrored index. Writes are atomic and listings scan the small local metadata
126
128
  tree deterministically.
127
129
 
130
+ Run events (`progress.jsonl` in the store's run directory) form a hash chain.
131
+ Each event carries `seq`, `prev_hash` and `hash`, a SHA-256 over its canonical
132
+ JSON. Appends take a short cross-process lock so concurrent writers, such as a
133
+ cancel from another terminal, extend one chain. The lock records its owner's
134
+ pid and host, so a crashed owner's lock is reclaimed at once and a live owner
135
+ is never broken. Appends read only the log's tail. A line torn by a crash is
136
+ closed off and skipped, and the audit counts it as unreadable. The completion event records
137
+ the report's SHA-256 and the artifact manifest's SHA-256. The report embeds a
138
+ `provenance` block (software, execution target, spec, base model, data
139
+ digests, hyperparameters). `tt runs audit` verifies the chain and report
140
+ digests and reports the first broken link. Events written by older versions
141
+ have no hashes and are reported as legacy.
142
+
128
143
  Each real adapter has an atomic `artifact-manifest.json` with the expected PEFT
129
144
  files, byte sizes, and SHA-256 hashes. A model is registered immediately after
130
145
  training and manifest verification, so a later evaluation failure does not
@@ -139,6 +154,29 @@ The runner config controls:
139
154
  - CUDA or CPU evaluation, with local serving through the CUDA vLLM runtime;
140
155
  - deterministic generation and scoring limits.
141
156
 
157
+ ## Hugging Face downloads
158
+
159
+ Model and dataset prefetch share `hub_download.py`. It resolves the requested
160
+ revision to one commit before downloading, so retries never mix files from two
161
+ commits. Network and server errors are retried with exponential backoff (2s up
162
+ to 60s, `TT_HF_DOWNLOAD_MAX_ATTEMPTS` attempts, default 10). Repo, auth,
163
+ revision and disk-full errors fail at once. Completed files stay in the cache,
164
+ so a rerun after Ctrl-C or a crash downloads only what is missing. A file
165
+ interrupted mid-transfer restarts from its beginning.
166
+
167
+ Progress is measured from the cache blob directory against the repo's file
168
+ sizes and printed as `@@tt-progress {json}` lines. The process runner sends
169
+ them to the reporter instead of the log stream. A terminal shows an in-place
170
+ bar with rate and ETA. Captured output gets a line every 10% or 30 seconds.
171
+ The parent `tt` process sets `TT_PROGRESS_TTY` for the local runtime child,
172
+ whose stderr is a pipe.
173
+
174
+ Dataset runs resolve the cached snapshot in Node without network access.
175
+ Each split file must link into the repo's blob store, and its digest must match
176
+ the blob name. With `columns`, records are converted to chat rows under
177
+ `<artifactRoot>/datasets/huggingface/`. The conversion path is keyed by the
178
+ source digests, the mapping and the system message.
179
+
142
180
  The training project, Python entrypoints, working directory, and forwarded
143
181
  runtime settings are internal and fixed. Every stage uses the locked `uv`
144
182
  project included in the npm package. Locally, the mutable virtual environment
@@ -140,6 +140,8 @@ Run these commands on your laptop in the project directory. Adapter workflow:
140
140
  # Fetch the base model locally first.
141
141
  tt validate tunedtensor.json
142
142
  tt models prefetch tunedtensor.json
143
+ # Only when dataset_prebuilt.huggingface is set:
144
+ tt datasets prefetch tunedtensor.json
143
145
  tt doctor tunedtensor.json
144
146
  tt pipeline run --spec tunedtensor.json --dry-run
145
147
  tt pipeline run --spec tunedtensor.json
@@ -122,6 +122,13 @@ resolve beside the spec. New projects default to `.tuned-tensor/artifacts` and
122
122
  Foundation supports `runtime.artifactRoot` and `runtime.gpu`; its evaluation
123
123
  and training settings remain under `foundation`.
124
124
 
125
+ `dataset_prebuilt.huggingface` names a Hugging Face dataset repo, an optional
126
+ pinned `revision` and optional `columns` (`input`/`output` field names). Its
127
+ split paths are then files inside that repo. `tt datasets prefetch` downloads
128
+ them, and `tt pipeline run` downloads them automatically if they are missing.
129
+ Runs record the resolved commit and file digests in the report. See the README
130
+ section "Train on a Hugging Face dataset".
131
+
125
132
  Only `tunedtensor.json` is required. `tt pipeline init --spec tunedtensor.json`
126
133
  embeds an advanced recipe when needed; omit this step to keep automatic derivation.
127
134
  An explicit `--file` cannot override a conflicting embedded recipe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuned-tensor/cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Local TT agent and CUDA fine-tuning CLI for Tuned Tensor workflows",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,384 @@
1
+ """Resumable, observable Hugging Face Hub snapshot downloads.
2
+
3
+ Model and dataset prefetch share this module so both get the same behavior:
4
+
5
+ * the requested revision is resolved once to an immutable commit, so retries
6
+ can never mix files from two commits when a branch moves mid-download;
7
+ * transient network failures are retried with exponential backoff. Completed
8
+ files stay in the Hugging Face cache, so each retry only fetches what is
9
+ still missing;
10
+ * progress is emitted as ``@@tt-progress {json}`` lines on stdout, which the
11
+ Node runner renders as a progress bar. huggingface_hub's own tqdm bars are
12
+ disabled because redrawn carriage-return output is unreadable in logs.
13
+
14
+ huggingface_hub is imported lazily so the pure helpers stay testable with the
15
+ system Python.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import errno
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import sys
25
+ import threading
26
+ import time
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Callable, Iterable
30
+
31
+ PROGRESS_PREFIX = "@@tt-progress "
32
+ COMMIT = re.compile(r"[0-9a-fA-F]{40}")
33
+ DEFAULT_MAX_ATTEMPTS = 10
34
+ MAX_BACKOFF_SECONDS = 60.0
35
+ # A per-request read timeout that tolerates slow or briefly stalled Wi-Fi.
36
+ DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = "60"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PlannedFile:
41
+ """One repository file expected in the snapshot, keyed by its cache blob name."""
42
+
43
+ path: str
44
+ size: int
45
+ etag: str
46
+
47
+
48
+ def configure_download_environment() -> None:
49
+ """Must run before huggingface_hub is imported; it reads these at import."""
50
+ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
51
+ os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", DEFAULT_DOWNLOAD_TIMEOUT_SECONDS)
52
+
53
+
54
+ def repo_folder_name(repo_id: str, repo_type: str) -> str:
55
+ return f"{repo_type}s--{repo_id.replace('/', '--')}"
56
+
57
+
58
+ def max_attempts_from_env(environ: dict[str, str] | None = None) -> int:
59
+ raw = (environ if environ is not None else os.environ).get("TT_HF_DOWNLOAD_MAX_ATTEMPTS")
60
+ if raw is None or not raw.strip():
61
+ return DEFAULT_MAX_ATTEMPTS
62
+ try:
63
+ value = int(raw)
64
+ except ValueError as exc:
65
+ raise ValueError("TT_HF_DOWNLOAD_MAX_ATTEMPTS must be an integer from 1 to 100") from exc
66
+ if not 1 <= value <= 100:
67
+ raise ValueError("TT_HF_DOWNLOAD_MAX_ATTEMPTS must be an integer from 1 to 100")
68
+ return value
69
+
70
+
71
+ def backoff_seconds(attempt: int) -> float:
72
+ """Delay before retry ``attempt`` (1-based): 2, 4, 8, ... capped at 60s."""
73
+ return min(MAX_BACKOFF_SECONDS, float(2 ** attempt))
74
+
75
+
76
+ class IncompleteDownloadError(RuntimeError):
77
+ """snapshot_download returned a cached folder that still lacks planned files."""
78
+
79
+
80
+ def is_retryable(error: BaseException) -> bool:
81
+ """Retry network and server failures; fail fast on caller or auth errors.
82
+
83
+ huggingface_hub reports a connection failure on a file that is not cached
84
+ yet as LocalEntryNotFoundError (a FileNotFoundError), so that and
85
+ IncompleteSnapshotError are retried during downloads.
86
+ """
87
+ if isinstance(error, (KeyboardInterrupt, SystemExit)):
88
+ return False
89
+ if isinstance(error, OSError) and error.errno in (errno.ENOSPC, errno.EACCES, errno.EROFS, errno.EDQUOT):
90
+ return False
91
+ name = type(error).__name__
92
+ if name in {
93
+ "RepositoryNotFoundError",
94
+ "GatedRepoError",
95
+ "RevisionNotFoundError",
96
+ "RemoteEntryNotFoundError",
97
+ "DisabledRepoError",
98
+ "OfflineModeIsEnabled",
99
+ }:
100
+ return False
101
+ if isinstance(error, (ValueError, TypeError)):
102
+ return False
103
+ response = getattr(error, "response", None)
104
+ status = getattr(response, "status_code", None)
105
+ if isinstance(status, int) and 400 <= status < 500 and status not in (408, 429):
106
+ return False
107
+ return True
108
+
109
+
110
+ def plan_files(siblings: Iterable[Any]) -> list[PlannedFile]:
111
+ """Build the expected cache inventory from repo_info(files_metadata=True)."""
112
+ planned: list[PlannedFile] = []
113
+ for sibling in siblings:
114
+ lfs = getattr(sibling, "lfs", None)
115
+ etag = getattr(lfs, "sha256", None) if lfs is not None else None
116
+ etag = etag or getattr(sibling, "blob_id", None)
117
+ size = getattr(sibling, "size", None)
118
+ if not etag or not isinstance(size, int):
119
+ # Without a blob name and size the file cannot be tracked; the
120
+ # download still fetches and verifies it, only progress is coarser.
121
+ continue
122
+ planned.append(PlannedFile(path=sibling.rfilename, size=size, etag=str(etag)))
123
+ return planned
124
+
125
+
126
+ def downloaded_bytes(blobs: Path, planned: PlannedFile) -> tuple[int, bool]:
127
+ """Bytes present for one planned file and whether its blob is complete."""
128
+ if (blobs / planned.etag).is_file():
129
+ return planned.size, True
130
+ partial = 0
131
+ try:
132
+ for candidate in blobs.glob(f"{planned.etag}*.incomplete"):
133
+ try:
134
+ partial = max(partial, candidate.stat().st_size)
135
+ except OSError:
136
+ continue
137
+ except OSError:
138
+ return 0, False
139
+ return min(partial, planned.size), False
140
+
141
+
142
+ def measure(blobs: Path, planned: list[PlannedFile]) -> dict[str, int]:
143
+ completed_bytes = 0
144
+ completed_files = 0
145
+ for item in planned:
146
+ size, done = downloaded_bytes(blobs, item)
147
+ completed_bytes += size
148
+ completed_files += int(done)
149
+ return {
150
+ "completed_bytes": completed_bytes,
151
+ "total_bytes": sum(item.size for item in planned),
152
+ "files_completed": completed_files,
153
+ "files_total": len(planned),
154
+ }
155
+
156
+
157
+ _STDOUT_LOCK = threading.Lock()
158
+
159
+
160
+ def write_line(text: str, stream: Any = None) -> None:
161
+ """Write one whole line; the progress thread and log messages share stdout."""
162
+ target = stream if stream is not None else sys.stdout
163
+ with _STDOUT_LOCK:
164
+ target.write(text + "\n")
165
+ target.flush()
166
+
167
+
168
+ def emit_progress(event: dict[str, Any], stream: Any = None) -> None:
169
+ write_line(PROGRESS_PREFIX + json.dumps(event, separators=(",", ":")), stream)
170
+
171
+
172
+ class ProgressMonitor:
173
+ """Polls the cache blob directory and emits progress while a download runs."""
174
+
175
+ def __init__(
176
+ self,
177
+ blobs: Path,
178
+ planned: list[PlannedFile],
179
+ label: str,
180
+ emit: Callable[[dict[str, Any]], None] = emit_progress,
181
+ interval: float = 0.5,
182
+ heartbeat: float = 5.0,
183
+ ) -> None:
184
+ self.blobs = blobs
185
+ self.planned = planned
186
+ self.label = label
187
+ self.emit = emit
188
+ self.interval = interval
189
+ self.heartbeat = heartbeat
190
+ self._stop = threading.Event()
191
+ self._thread: threading.Thread | None = None
192
+ self._last: dict[str, int] | None = None
193
+ self._last_emit = 0.0
194
+
195
+ def snapshot(self, force: bool = False, phase: str = "download") -> None:
196
+ current = measure(self.blobs, self.planned)
197
+ now = time.monotonic()
198
+ if not force and current == self._last and now - self._last_emit < self.heartbeat:
199
+ return
200
+ self._last = current
201
+ self._last_emit = now
202
+ self.emit({"label": self.label, "phase": phase, **current})
203
+
204
+ def _run(self) -> None:
205
+ while not self._stop.wait(self.interval):
206
+ try:
207
+ self.snapshot()
208
+ except Exception: # Progress is best effort; never break the download.
209
+ pass
210
+
211
+ def __enter__(self) -> "ProgressMonitor":
212
+ self.snapshot(force=True)
213
+ self._thread = threading.Thread(target=self._run, name="tt-progress", daemon=True)
214
+ self._thread.start()
215
+ return self
216
+
217
+ def __exit__(self, *_: object) -> None:
218
+ self._stop.set()
219
+ if self._thread:
220
+ self._thread.join(timeout=2)
221
+
222
+
223
+ def with_retries(
224
+ action: Callable[[], Any],
225
+ *,
226
+ description: str,
227
+ max_attempts: int,
228
+ sleep: Callable[[float], None] | None = None,
229
+ log: Callable[[str], None] = write_line,
230
+ ) -> Any:
231
+ for attempt in range(1, max_attempts + 1):
232
+ try:
233
+ return action()
234
+ except Exception as error: # noqa: BLE001 - classified below
235
+ if attempt >= max_attempts or not is_retryable(error):
236
+ raise
237
+ delay = backoff_seconds(attempt)
238
+ log(
239
+ f"{description} interrupted ({type(error).__name__}: {error}). "
240
+ f"Retrying in {delay:.0f}s (attempt {attempt + 1}/{max_attempts}); "
241
+ "completed files are kept in the cache."
242
+ )
243
+ (sleep or time.sleep)(delay)
244
+ raise AssertionError("unreachable")
245
+
246
+
247
+ def verify_blob(path: Path) -> bool:
248
+ """Check a cached file against its content-addressed blob name.
249
+
250
+ Returns True when the blob name is a recognized digest and it matches;
251
+ False when the name carries no digest (for example a cache without
252
+ symlinks). Raises ValueError on a mismatch.
253
+ """
254
+ return verify_blob_digest(path)[0]
255
+
256
+
257
+ def verify_blob_digest(path: Path) -> tuple[bool, str]:
258
+ """Like verify_blob, also returning the file's SHA-256 from the same read."""
259
+ blob = path.resolve()
260
+ expected = blob.name.lower()
261
+ sha256 = hashlib.sha256()
262
+ git_sha1 = hashlib.sha1(f"blob {blob.stat().st_size}\0".encode())
263
+ with blob.open("rb") as source:
264
+ for chunk in iter(lambda: source.read(8 * 1024 * 1024), b""):
265
+ sha256.update(chunk)
266
+ git_sha1.update(chunk)
267
+ digests = {64: sha256.hexdigest(), 40: git_sha1.hexdigest()}
268
+ if len(expected) in digests and all(character in "0123456789abcdef" for character in expected):
269
+ if digests[len(expected)] != expected:
270
+ raise ValueError(f"Cached Hugging Face blob checksum mismatch: {path}")
271
+ return True, digests[64]
272
+ return False, digests[64]
273
+
274
+
275
+ def download_snapshot(
276
+ *,
277
+ repo_id: str,
278
+ repo_type: str,
279
+ revision: str | None,
280
+ allow_patterns: list[str] | None = None,
281
+ ignore_patterns: list[str] | None = None,
282
+ local_files_only: bool = False,
283
+ label: str,
284
+ ) -> tuple[str, str]:
285
+ """Download (or verify locally) one snapshot. Returns (path, commit sha)."""
286
+ from huggingface_hub import constants, snapshot_download
287
+
288
+ token = os.getenv("HF_TOKEN")
289
+ if local_files_only:
290
+ path = snapshot_download(
291
+ repo_id=repo_id,
292
+ repo_type=repo_type,
293
+ revision=revision,
294
+ token=token,
295
+ allow_patterns=allow_patterns,
296
+ ignore_patterns=ignore_patterns,
297
+ local_files_only=True,
298
+ )
299
+ return path, Path(path).name
300
+
301
+ if revision and COMMIT.fullmatch(revision):
302
+ # A pinned commit that is already fully cached needs no network, so
303
+ # prefetch keeps working offline. huggingface_hub checks completeness
304
+ # against its cached tree listing and raises if files are missing.
305
+ try:
306
+ path = snapshot_download(
307
+ repo_id=repo_id,
308
+ repo_type=repo_type,
309
+ revision=revision,
310
+ token=token,
311
+ allow_patterns=allow_patterns,
312
+ ignore_patterns=ignore_patterns,
313
+ local_files_only=True,
314
+ )
315
+ write_line(f"{label}: {repo_id}@{revision} is already cached; no download needed.")
316
+ return path, revision
317
+ except Exception: # noqa: BLE001 - not cached or incomplete: download below
318
+ pass
319
+
320
+ from huggingface_hub import HfApi
321
+ from huggingface_hub.utils import filter_repo_objects
322
+
323
+ max_attempts = max_attempts_from_env()
324
+ api = HfApi(token=token)
325
+ info = with_retries(
326
+ lambda: api.repo_info(repo_id, repo_type=repo_type, revision=revision, files_metadata=True),
327
+ description=f"Resolving {repo_id}",
328
+ max_attempts=max_attempts,
329
+ )
330
+ commit = str(info.sha)
331
+ siblings = list(filter_repo_objects(
332
+ info.siblings or [],
333
+ allow_patterns=allow_patterns,
334
+ ignore_patterns=ignore_patterns,
335
+ key=lambda sibling: sibling.rfilename,
336
+ ))
337
+ planned = plan_files(siblings)
338
+ blobs = Path(constants.HF_HUB_CACHE) / repo_folder_name(repo_id, repo_type) / "blobs"
339
+ blobs.mkdir(parents=True, exist_ok=True)
340
+ total = sum(item.size for item in planned)
341
+ write_line(
342
+ f"{label}: {repo_id}@{commit} — {len(planned)} file(s), {total} bytes. "
343
+ "Interrupted downloads can be restarted; completed files are reused."
344
+ )
345
+ monitor = ProgressMonitor(blobs, planned, label)
346
+ with monitor:
347
+ def attempt() -> str:
348
+ path = snapshot_download(
349
+ repo_id=repo_id,
350
+ repo_type=repo_type,
351
+ revision=commit,
352
+ token=token,
353
+ allow_patterns=allow_patterns,
354
+ ignore_patterns=ignore_patterns,
355
+ )
356
+ # If the Hub becomes unreachable mid-way, huggingface_hub may hand
357
+ # back the partial cached folder; treat that as a retryable failure.
358
+ missing = [sibling.rfilename for sibling in siblings if not (Path(path) / sibling.rfilename).exists()]
359
+ if missing:
360
+ raise IncompleteDownloadError(f"{len(missing)} file(s) still missing, e.g. {missing[0]}")
361
+ return path
362
+
363
+ path = with_retries(attempt, description=f"Downloading {repo_id}", max_attempts=max_attempts)
364
+ monitor.snapshot(force=True, phase="downloaded")
365
+ if revision and revision != commit:
366
+ # Downloading by commit skips huggingface_hub's ref bookkeeping. Record
367
+ # the branch or tag -> commit mapping it would have written, so later
368
+ # offline resolution of an unpinned revision finds this snapshot.
369
+ write_ref(Path(constants.HF_HUB_CACHE) / repo_folder_name(repo_id, repo_type), revision, commit)
370
+ elif not revision:
371
+ write_ref(Path(constants.HF_HUB_CACHE) / repo_folder_name(repo_id, repo_type), "main", commit)
372
+ return path, commit
373
+
374
+
375
+ def write_ref(repository: Path, revision: str, commit: str) -> None:
376
+ if revision.lower() == commit.lower():
377
+ return
378
+ ref = repository / "refs" / revision
379
+ if repository.resolve() not in ref.resolve().parents:
380
+ raise ValueError(f"Invalid Hugging Face revision name: {revision}")
381
+ ref.parent.mkdir(parents=True, exist_ok=True)
382
+ temporary = ref.with_name(f".{ref.name}.{os.getpid()}.tmp")
383
+ temporary.write_text(commit, encoding="utf-8")
384
+ os.replace(temporary, ref)
@@ -1,11 +1,11 @@
1
1
  import argparse
2
- import hashlib
3
2
  import json
4
3
  import os
5
4
  import re
6
5
  from pathlib import Path
7
6
  from typing import Any
8
7
 
8
+ from hub_download import configure_download_environment, download_snapshot, verify_blob
9
9
  from model_contract import (
10
10
  assert_certified_base_model,
11
11
  assert_certified_base_model_revision,
@@ -77,15 +77,6 @@ def configure_hugging_face_cache(cache_home: str | None) -> None:
77
77
  os.environ.pop(deprecated, None)
78
78
 
79
79
 
80
- def hash_file(path: Path, algorithm: str, prefix: bytes = b"") -> str:
81
- digest = hashlib.new(algorithm)
82
- digest.update(prefix)
83
- with path.open("rb") as source:
84
- for chunk in iter(lambda: source.read(8 * 1024 * 1024), b""):
85
- digest.update(chunk)
86
- return digest.hexdigest()
87
-
88
-
89
80
  def verify_snapshot(snapshot: Path, expected_model_id: str | None = None) -> tuple[list[Path], int]:
90
81
  config = snapshot / "config.json"
91
82
  if not config.is_file() or config.stat().st_size == 0:
@@ -106,6 +97,9 @@ def verify_snapshot(snapshot: Path, expected_model_id: str | None = None) -> tup
106
97
 
107
98
  weight_files = sorted(snapshot.glob("*.safetensors")) + sorted(snapshot.glob("pytorch_model*.bin"))
108
99
  index_files = sorted(snapshot.glob("*.safetensors.index.json")) + sorted(snapshot.glob("pytorch_model*.bin.index.json"))
100
+ # Keep lexical snapshot names: normal HF file symlinks target sibling blobs.
101
+ files = [path for path in snapshot.rglob("*") if path.is_file()]
102
+ inventory = set(files)
109
103
  for index in index_files:
110
104
  try:
111
105
  weight_map = json.loads(index.read_text(encoding="utf-8")).get("weight_map", {})
@@ -113,8 +107,13 @@ def verify_snapshot(snapshot: Path, expected_model_id: str | None = None) -> tup
113
107
  raise ValueError(f"Cached snapshot has an invalid weight index: {index}") from exc
114
108
  if not isinstance(weight_map, dict) or not weight_map:
115
109
  raise ValueError(f"Cached snapshot has an empty weight index: {index}")
116
- for name in set(weight_map.values()):
117
- if not isinstance(name, str) or not (snapshot / name).is_file():
110
+ for name in weight_map.values():
111
+ if (not isinstance(name, str) or not name
112
+ or re.match(r"^(?:[A-Za-z]:|[/\\])", name)
113
+ or ".." in re.split(r"[/\\]", name)):
114
+ raise ValueError("Cached snapshot has an invalid indexed weight shard path")
115
+ shard = snapshot / name
116
+ if shard not in inventory or shard.stat().st_size == 0:
118
117
  raise ValueError(f"Cached snapshot is missing an indexed weight shard: {name}")
119
118
  if not weight_files:
120
119
  raise ValueError(f"Cached snapshot contains no Transformers model weights: {snapshot}")
@@ -134,20 +133,7 @@ def verify_snapshot(snapshot: Path, expected_model_id: str | None = None) -> tup
134
133
  if not tokenizer_config.is_file() or not any(path.is_file() and path.stat().st_size > 0 for path in vocabulary):
135
134
  raise ValueError(f"Cached snapshot is missing tokenizer metadata or vocabulary files: {snapshot}")
136
135
 
137
- files = [path for path in snapshot.rglob("*") if path.is_file()]
138
- verified_blobs = 0
139
- for path in files:
140
- blob = path.resolve()
141
- expected = blob.name.lower()
142
- if len(expected) == 64 and all(character in "0123456789abcdef" for character in expected):
143
- actual = hash_file(blob, "sha256")
144
- elif len(expected) == 40 and all(character in "0123456789abcdef" for character in expected):
145
- actual = hash_file(blob, "sha1", f"blob {blob.stat().st_size}\0".encode())
146
- else:
147
- continue
148
- if actual != expected:
149
- raise ValueError(f"Cached Hugging Face blob checksum mismatch: {path}")
150
- verified_blobs += 1
136
+ verified_blobs = sum(1 for path in files if verify_blob(path))
151
137
  return files, verified_blobs
152
138
 
153
139
 
@@ -171,17 +157,20 @@ def main() -> None:
171
157
 
172
158
  # Import only after the cache environment is final. huggingface_hub reads
173
159
  # these variables while importing its constants module.
174
- from huggingface_hub import constants, snapshot_download
160
+ configure_download_environment()
161
+ from huggingface_hub import constants
175
162
 
176
- action = "Verifying cached" if payload.get("local_files_only") else "Prefetching"
163
+ local_only = bool(payload.get("local_files_only", False))
164
+ action = "Verifying cached" if local_only else "Prefetching"
177
165
  print(f"{action} {base_model} in {constants.HF_HUB_CACHE}...", flush=True)
178
- snapshot_path = snapshot_download(
166
+ snapshot_path, _ = download_snapshot(
179
167
  repo_id=base_model,
168
+ repo_type="model",
180
169
  revision=payload.get("revision"),
181
- token=os.getenv("HF_TOKEN"),
182
170
  allow_patterns=ALLOW_PATTERNS,
183
171
  ignore_patterns=IGNORE_PATTERNS + ([] if is_certified_base_model(base_model) else ["pytorch_model*.bin"]),
184
- local_files_only=bool(payload.get("local_files_only", False)),
172
+ local_files_only=local_only,
173
+ label="model_prefetch",
185
174
  )
186
175
  snapshot = Path(snapshot_path)
187
176
  snapshot_revision = snapshot.name