Coverage for src/lilbee/catalog/download.py: 100%

328 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-04 17:08 +0000

1"""GGUF download, mmproj resolution, post-download hooks.""" 

2 

3import fnmatch 

4import logging 

5import os 

6import shutil 

7import sys 

8import threading 

9import time 

10from collections.abc import Callable 

11from http import HTTPStatus 

12from pathlib import Path 

13from typing import Any 

14 

15import httpx 

16from pydantic import BaseModel 

17 

18from lilbee.catalog.compat import UnsupportedQuantError, classify, file_header 

19from lilbee.catalog.download_progress import ProgressCallback, _ProgressTracker 

20from lilbee.catalog.hf_client import ( 

21 DEFAULT_TIMEOUT, 

22 HF_API_URL, 

23 hf_headers, 

24 hf_token, 

25 repo_has_mmproj, 

26) 

27from lilbee.catalog.models import CatalogModel 

28from lilbee.catalog.refs import ( 

29 DEFAULT_MMPROJ_PATTERN, 

30 FLOAT_QUANTS, 

31 WILDCARD, 

32 quant_label, 

33 rank_gguf_candidates, 

34 split_shard_filenames, 

35) 

36from lilbee.catalog.types import ModelCompat, ModelTask 

37from lilbee.runtime.cancellation import CancelSignal, TaskCancelledError 

38 

39CompleteCallback = Callable[[CatalogModel, Path], None] 

40# Raises UnsupportedQuantError when the engine cannot decode the named file. 

41LoadCheck = Callable[[str, str], None] 

42 

43log = logging.getLogger(__name__) 

44 

45 

46def _models_dir() -> Path: 

47 """Deferred cfg read: a module-level cfg import is circular via Config()'s 

48 model-ref validator (config -> model_ref -> catalog -> here -> config).""" 

49 from lilbee.core.config.model import cfg 

50 

51 return cfg.models_dir 

52 

53 

54class DownloadConfig(BaseModel): 

55 model_config = {"arbitrary_types_allowed": True} 

56 

57 repo_id: str 

58 filename: str 

59 token: str | None 

60 force_download: bool = False 

61 cache_dir: str | None = None 

62 tqdm_class: Any = None 

63 

64 

65_BYTES_PER_GB = 1024**3 

66 

67 

68def _repo_partial_bytes(models_dir: Path, hf_repo: str) -> int: 

69 """Bytes an interrupted attempt at *hf_repo* already holds on disk. 

70 

71 A resume needs only the remainder, so these count toward available space. 

72 """ 

73 from huggingface_hub.file_download import repo_folder_name 

74 

75 repo_dir = models_dir / repo_folder_name(repo_id=hf_repo, repo_type="model") 

76 if not repo_dir.is_dir(): 

77 return 0 

78 return sum(f.stat().st_size for f in repo_dir.glob("blobs/*.incomplete") if f.is_file()) 

79 

80 

81def _free_bytes(path: Path) -> int | None: 

82 """Free space on the volume that will hold *path*, which need not exist yet. 

83 

84 Measured at the nearest existing ancestor, since shutil.disk_usage raises on 

85 a missing path. 

86 """ 

87 probe = path.resolve() 

88 while True: 

89 try: 

90 return shutil.disk_usage(probe).free 

91 except OSError: 

92 if probe.parent == probe: 

93 return None 

94 probe = probe.parent 

95 

96 

97def disk_shortfall(models_dir: Path, hf_repo: str, needed: int) -> str | None: 

98 """Describe why *needed* bytes will not fit, or None when they will.""" 

99 if needed == _SIZE_UNKNOWN: 

100 return None # offline or unresolvable; nothing to compare against 

101 free = _free_bytes(models_dir) 

102 if free is None: 

103 return None # unmeasurable volume; let the download report the truth 

104 available = free + _repo_partial_bytes(models_dir, hf_repo) 

105 if needed <= available: 

106 return None 

107 return ( 

108 f"Not enough disk space for {hf_repo}: needs " 

109 f"{needed / _BYTES_PER_GB:.1f} GB, {available / _BYTES_PER_GB:.1f} GB free." 

110 ) 

111 

112 

113def _require_disk_space(entry: CatalogModel, models_dir: Path, needed: int) -> None: 

114 """Refuse a download the disk cannot hold, naming the shortfall. 

115 

116 huggingface_hub only warns, and the xet path reports a full disk as a 

117 reconstruction error naming neither the disk nor the file. 

118 """ 

119 message = disk_shortfall(models_dir, entry.hf_repo, needed) 

120 if message is not None: 

121 raise RuntimeError(message) 

122 

123 

124_LOW_DISK_FLOOR = 512 * 1024**2 

125"""Free bytes below which a failed download is reported as a full disk. 

126 

127Catches a volume that filled mid-transfer, which the pre-flight cannot see.""" 

128 

129 

130def _raise_if_disk_exhausted( 

131 entry: CatalogModel, config: DownloadConfig, cause: BaseException 

132) -> None: 

133 """Re-raise a failed download as a disk problem when the volume is full. 

134 

135 Low free space is a heuristic, not a diagnosis, so *cause* stays in the 

136 message. 

137 """ 

138 if config.cache_dir is None: 

139 return 

140 try: 

141 free = shutil.disk_usage(config.cache_dir).free 

142 except OSError: 

143 return # the path went away with the failure; leave the original error 

144 if free >= _LOW_DISK_FLOOR: 

145 return 

146 raise RuntimeError( 

147 f"Ran out of disk space downloading {entry.hf_repo}: " 

148 f"{free / _BYTES_PER_GB:.1f} GB free. {type(cause).__name__}: {cause}" 

149 ) from None 

150 

151 

152_XET_HIGH_PERFORMANCE_ENV = "HF_XET_HIGH_PERFORMANCE" 

153 

154_XET_DISABLE_ENV = "HF_HUB_DISABLE_XET" 

155 

156 

157def _disable_xet_where_it_stalls() -> None: 

158 """Fall back to the plain HTTP download path on Windows. 

159 

160 hf_xet transfers stall or deadlock on Windows (xet-core issues #446, 

161 #789, #850), while the plain path downloads at line speed. Everywhere 

162 else xet stays on deliberately: it is the fast path. A user who 

163 exported the variable keeps whatever they chose. huggingface_hub 

164 parses the variable once at import, so the hub constant must change 

165 too; the environment write covers worker subprocesses, which parse it 

166 fresh. 

167 """ 

168 if sys.platform != "win32": 

169 return 

170 if _XET_DISABLE_ENV in os.environ: 

171 return 

172 from huggingface_hub import constants 

173 

174 os.environ[_XET_DISABLE_ENV] = "1" 

175 constants.HF_HUB_DISABLE_XET = True 

176 

177 

178def _apply_fast_download_mode() -> None: 

179 """Publish the high-performance setting to xet before it builds a session. 

180 

181 hf_xet reads it from the environment in Rust and caches it when the session 

182 is built, so a change lands on restart. 

183 """ 

184 # circular: catalog.download -> core.config via cfg, the same cycle 

185 # _models_dir documents (config -> model_ref -> catalog -> here). 

186 from lilbee.core.config.model import cfg 

187 

188 if cfg.fast_model_downloads: 

189 os.environ[_XET_HIGH_PERFORMANCE_ENV] = "1" 

190 else: 

191 os.environ.pop(_XET_HIGH_PERFORMANCE_ENV, None) 

192 

193 

194_STALL_WINDOW_S = 60.0 

195"""Seconds per measurement window; a transfer below the byte floor for a 

196whole window counts as stalled. 

197 

198Well past the hub's own 10s read timeout and its resume retries, so the 

199guard only fires on transfers those mechanisms cannot wake.""" 

200 

201_STALL_FLOOR_BYTES = 256 * 1024 

202"""Minimum bytes per window for a transfer to count as alive. 

203 

204A wedged connection can trickle a few bytes a minute, which an any-activity 

205check reads as progress; ~4 KB/s is far below any usable model download.""" 

206 

207_STALL_POLL_S = 5.0 

208 

209_STALL_RETRIES = 2 

210 

211 

212def _abort_stalled_transfer() -> None: 

213 """Break a wedged transfer so the blocked download thread raises. 

214 

215 Covers both transports: the xet session abort stops a deadlocked Rust 

216 transfer (a no-op without one), and closing the hub's shared client 

217 closes the plain path's socket under its blocked read. The next hub 

218 call builds a fresh client. The session abort is safe here because a 

219 process runs at most one download; concurrent downloads each run in 

220 their own child process. 

221 """ 

222 from huggingface_hub.utils._http import close_session 

223 from huggingface_hub.utils._xet import abort_xet_session 

224 

225 abort_xet_session() 

226 close_session() 

227 

228 

229class _StallGuard: 

230 """Aborts a transfer that reports no bytes for the stall window. 

231 

232 A wedged transfer blocks forever with the task showing active: hf_xet 

233 can deadlock before its first byte, a dead socket never wakes the 

234 plain path's read, and a dying connection can trickle bytes too slowly 

235 to ever finish. The guard rides the same progress stream the task bar 

236 shows; when a window passes under the byte floor, the abort makes the 

237 blocked thread raise, and the caller resumes from the .incomplete file. 

238 """ 

239 

240 def __init__( 

241 self, 

242 window_s: float = _STALL_WINDOW_S, 

243 poll_s: float = _STALL_POLL_S, 

244 floor_bytes: int = _STALL_FLOOR_BYTES, 

245 ) -> None: 

246 self._window_s = window_s 

247 self._poll_s = poll_s 

248 self._floor_bytes = floor_bytes 

249 self._window_start = time.monotonic() 

250 self._window_bytes = 0 

251 self._stop = threading.Event() 

252 self._thread: threading.Thread | None = None 

253 self.fired = False 

254 

255 def pulse(self, n: float = 0) -> None: 

256 """Count transferred bytes; called from the download thread's tqdm.""" 

257 self._window_bytes += int(n) 

258 

259 def wrap_tqdm(self, tqdm_class: Any) -> Any: 

260 """Subclass *tqdm_class* (or the hub's default) to pulse on every update. 

261 

262 ``update_transfer`` is only defined when the base has it: the hub 

263 feature-detects the method, so adding it to a base that lacks it 

264 would advertise a stream the base cannot aggregate. 

265 """ 

266 from huggingface_hub.utils.tqdm import tqdm as hub_tqdm 

267 

268 guard = self 

269 base = tqdm_class if tqdm_class is not None else hub_tqdm 

270 

271 class _Pulsing(base): # type: ignore[misc, valid-type] 

272 def update(self, n: float = 1) -> bool | None: 

273 guard.pulse(n) 

274 super().update(n) 

275 return None 

276 

277 if not hasattr(base, "update_transfer"): 

278 return _Pulsing 

279 

280 class _PulsingTransfer(_Pulsing): 

281 def update_transfer(self, n: float = 1) -> bool | None: 

282 guard.pulse(n) 

283 super().update_transfer(n) 

284 return None 

285 

286 return _PulsingTransfer 

287 

288 def _watch(self) -> None: 

289 while not self._stop.wait(self._poll_s): 

290 if not self._keep_watching(): 

291 return 

292 

293 def _keep_watching(self) -> bool: 

294 """One tick: True to keep watching, False once fired or stopped.""" 

295 now = time.monotonic() 

296 if now - self._window_start < self._window_s: 

297 return True 

298 if self._stop.is_set(): 

299 return False # the transfer finished while this tick was deciding 

300 if self._window_bytes >= self._floor_bytes: 

301 self._window_start = now 

302 self._window_bytes = 0 

303 return True 

304 self.fired = True 

305 _abort_stalled_transfer() 

306 return False 

307 

308 def __enter__(self) -> "_StallGuard": 

309 self._thread = threading.Thread( 

310 target=self._watch, name="download-stall-guard", daemon=True 

311 ) 

312 self._thread.start() 

313 return self 

314 

315 def __exit__(self, *exc_info: object) -> None: 

316 self._stop.set() 

317 if self._thread is not None: 

318 self._thread.join(timeout=self._poll_s + 1) 

319 

320 

321def _download_with_stall_guard(entry: CatalogModel, config: DownloadConfig) -> Path: 

322 """Run the transfer under the stall guard, resuming after each stall. 

323 

324 huggingface_hub resumes from the .incomplete file, so a retry costs only 

325 the bytes since the stall. A failure with the guard quiet is a real 

326 error and propagates on the first attempt; cancellation always does. 

327 """ 

328 last_error: Exception | None = None 

329 for attempt in range(_STALL_RETRIES + 1): 

330 guard = _StallGuard() 

331 guarded = config.model_copy(update={"tqdm_class": guard.wrap_tqdm(config.tqdm_class)}) 

332 try: 

333 with guard: 

334 return _hf_download_or_translate(entry, guarded) 

335 except TaskCancelledError: 

336 raise 

337 except Exception as exc: 

338 if not guard.fired: 

339 raise 

340 last_error = exc 

341 log.warning( 

342 "Transfer of %s stalled (attempt %d/%d); resuming.", 

343 entry.hf_repo, 

344 attempt + 1, 

345 _STALL_RETRIES + 1, 

346 ) 

347 raise RuntimeError( 

348 f"Download of {entry.hf_repo} stalled {_STALL_RETRIES + 1} times with almost " 

349 "no data arriving. Check the network connection and retry; the finished part " 

350 "is kept and the download resumes where it stopped." 

351 ) from last_error 

352 

353 

354def _hf_download_or_translate(entry: CatalogModel, config: DownloadConfig) -> Path: 

355 """Run the HF download and translate every error class into a clean exception.""" 

356 from huggingface_hub import hf_hub_download 

357 from huggingface_hub.utils import EntryNotFoundError, GatedRepoError, RepositoryNotFoundError 

358 

359 _disable_xet_where_it_stalls() 

360 try: 

361 return Path(hf_hub_download(**config.model_dump(exclude_none=True))) 

362 except TaskCancelledError: 

363 raise 

364 except GatedRepoError: 

365 raise PermissionError( 

366 f"{entry.hf_repo} requires HuggingFace authentication. " 

367 "Set HF_TOKEN env var or visit the repo page to request access." 

368 ) from None 

369 except RepositoryNotFoundError: 

370 raise RuntimeError(f"Repository {entry.hf_repo!r} not found on HuggingFace.") from None 

371 except EntryNotFoundError: 

372 raise RuntimeError(_missing_file_message(entry.hf_repo, config.filename)) from None 

373 except (httpx.TimeoutException, httpx.ConnectError) as exc: 

374 raise RuntimeError(f"Network error downloading {entry.hf_repo}: {exc}") from None 

375 except OSError as exc: 

376 raise RuntimeError(f"I/O error downloading {entry.hf_repo}: {exc}") from None 

377 except Exception as exc: 

378 _raise_if_disk_exhausted(entry, config, exc) 

379 raise RuntimeError( 

380 f"Failed to download {entry.hf_repo}: {type(exc).__name__}: {exc}" 

381 ) from None 

382 

383 

384def download_model( 

385 entry: CatalogModel, 

386 *, 

387 on_progress: ProgressCallback | None = None, 

388 on_complete: CompleteCallback | None = None, 

389 cancel: CancelSignal | None = None, 

390) -> Path: 

391 """Download a GGUF model from HuggingFace to the models dir. 

392 Uses huggingface_hub for resumable downloads, caching, and auth. 

393 The optional *on_progress(downloaded, total)* callback receives byte counts. 

394 The optional *on_complete(entry, file_path)* callback runs after every file 

395 is on disk; modelhub uses it to write a registry manifest. For vision 

396 models, also downloads the mmproj (CLIP projection) file. 

397 

398 With a *cancel* signal the transfer runs in its own child process, and a 

399 set signal terminates that process mid-transfer; without one the transfer 

400 runs in this process and only ``on_progress`` raising can stop it. 

401 

402 A split GGUF has every shard fetched before the model is finalized, so the 

403 registry manifest (and thus "installed") only lands once the full set is on 

404 disk; an interrupted multi-part pull leaves the model not-installed and 

405 re-pullable rather than registered-but-unloadable. 

406 

407 Raises: 

408 PermissionError: gated repo requiring authentication 

409 RuntimeError: repo not found or download failure with details 

410 TaskCancelledError: the cancel signal was set 

411 """ 

412 _apply_fast_download_mode() 

413 models_dir = _models_dir() 

414 models_dir.mkdir(parents=True, exist_ok=True) 

415 token = hf_token() 

416 if cancel is None: 

417 dest = fetch_model_files(entry, models_dir, token, on_progress=on_progress) 

418 else: 

419 # circular: download -> download_process via fetch_model_files 

420 from lilbee.catalog.download_process import download_in_subprocess 

421 

422 dest = download_in_subprocess( 

423 entry, models_dir, token, on_progress=on_progress, cancel=cancel 

424 ) 

425 if on_complete is not None: 

426 on_complete(entry, dest) 

427 return dest 

428 

429 

430def fetch_model_files( 

431 entry: CatalogModel, 

432 models_dir: Path, 

433 token: str | None, 

434 *, 

435 on_progress: ProgressCallback | None = None, 

436) -> Path: 

437 """Fetch *entry*'s GGUF shards, plus its projector when the repo ships one. 

438 

439 Takes the models dir and token as arguments so a download child process 

440 can run it without reading cfg. Writes no registry state. 

441 """ 

442 filename = resolve_filename(entry) 

443 shards = split_shard_filenames(filename) 

444 dest = models_dir / shards[0] 

445 if all( 

446 (models_dir / shard).exists() 

447 and _cached_file_is_complete(entry.hf_repo, shard, models_dir / shard) 

448 for shard in shards 

449 ): 

450 log.info("Model already downloaded: %s", dest) 

451 if on_progress is not None: 

452 size = sum((models_dir / shard).stat().st_size for shard in shards) 

453 on_progress(size, size) # Report 100% immediately (every shard) 

454 _ensure_projector(entry, models_dir, token, on_progress=on_progress) 

455 return dest 

456 

457 shard_sizes = [fetch_expected_file_size(entry.hf_repo, shard) for shard in shards] 

458 sizes_known = all(size != _SIZE_UNKNOWN for size in shard_sizes) 

459 _require_disk_space(entry, models_dir, sum(shard_sizes) if sizes_known else 0) 

460 

461 # Sum the shard sizes up front so a multi-shard pull reports one monotonic 

462 # 0->100% against the real total, not N separate per-shard cycles. Only use 

463 # the sum when every shard size is known (0 = unresolved/offline); a partial 

464 # sum would undercount the total and let progress run past 100%. 

465 grand_total = sum(shard_sizes) if len(shards) > 1 and sizes_known else 0 

466 tracker = _ProgressTracker(on_progress, grand_total=grand_total) if on_progress else None 

467 shard_paths: list[Path] = [] 

468 for shard in shards: 

469 log.info("Downloading %s/%s → %s", entry.hf_repo, shard, models_dir) 

470 config = DownloadConfig( 

471 repo_id=entry.hf_repo, 

472 filename=shard, 

473 token=token, 

474 cache_dir=str(models_dir), 

475 tqdm_class=tracker.make_tqdm_class() if tracker else None, 

476 ) 

477 shard_path = _download_with_stall_guard(entry, config) 

478 shard_paths.append(shard_path) 

479 if tracker is not None: 

480 tracker.shard_done(shard_path.stat().st_size) 

481 first_shard_path = shard_paths[0] # the 00001-of-N shard llama.cpp loads from 

482 

483 if on_progress: 

484 total_size = sum(path.stat().st_size for path in shard_paths) 

485 if not tracker or not tracker.was_used: 

486 log.info("Model found in HuggingFace cache: %s", first_shard_path) 

487 on_progress(total_size, total_size) 

488 _ensure_projector(entry, models_dir, token, on_progress=on_progress) 

489 return first_shard_path 

490 

491 

492def _ensure_projector( 

493 entry: CatalogModel, 

494 models_dir: Path, 

495 token: str | None, 

496 *, 

497 on_progress: ProgressCallback | None = None, 

498) -> None: 

499 """Fetch the projector whenever the repo ships one, not only for VISION entries. 

500 

501 Dual-use VL repos (Qwen-VL, InternVL, SmolVLM, gemma-3) classify as chat by 

502 name and arch, and without their projector the vision role dies at plan 

503 time with a missing-mmproj warning a re-pull cannot cure. 

504 """ 

505 if entry.task == ModelTask.VISION or repo_has_mmproj(entry.hf_repo): 

506 _fetch_mmproj(entry, models_dir, token, on_progress=on_progress) 

507 

508 

509def download_mmproj( 

510 entry: CatalogModel, 

511 *, 

512 on_progress: ProgressCallback | None = None, 

513) -> Path | None: 

514 """Download the mmproj (CLIP projection) file for a vision model. 

515 Returns the path to the downloaded file, or None if no mmproj is configured. 

516 The optional ``on_progress`` callback receives ``(downloaded, total)`` byte 

517 counts and is wired through the same tqdm hook used by the main download. 

518 """ 

519 _apply_fast_download_mode() 

520 return _fetch_mmproj(entry, _models_dir(), hf_token(), on_progress=on_progress) 

521 

522 

523def _fetch_mmproj( 

524 entry: CatalogModel, 

525 models_dir: Path, 

526 token: str | None, 

527 *, 

528 on_progress: ProgressCallback | None = None, 

529) -> Path | None: 

530 """Fetch *entry*'s mmproj into *models_dir*, or None when the repo names none.""" 

531 mmproj_filename = _resolve_mmproj_filename(entry.hf_repo, DEFAULT_MMPROJ_PATTERN) 

532 if not mmproj_filename: 

533 log.warning("Could not resolve mmproj file for %s", entry.hf_repo) 

534 return None 

535 

536 tracker = _ProgressTracker(on_progress) if on_progress else None 

537 log.info("Downloading mmproj %s/%s → %s", entry.hf_repo, mmproj_filename, models_dir) 

538 _require_disk_space(entry, models_dir, fetch_expected_file_size(entry.hf_repo, mmproj_filename)) 

539 # The projector gets the same error translation and stall guard as the GGUF. 

540 path = _download_with_stall_guard( 

541 entry, 

542 DownloadConfig( 

543 repo_id=entry.hf_repo, 

544 filename=mmproj_filename, 

545 token=token, 

546 cache_dir=str(models_dir), 

547 tqdm_class=tracker.make_tqdm_class() if tracker else None, 

548 ), 

549 ) 

550 if on_progress is not None and (not tracker or not tracker.was_used): 

551 # Cache hit: HF returned the cached path without invoking tqdm. 

552 size = path.stat().st_size 

553 on_progress(size, size) 

554 return path 

555 

556 

557def _repo_sibling_files(hf_repo: str) -> list[str]: 

558 """Every filename the HuggingFace API lists for *hf_repo*. 

559 

560 Raises: 

561 PermissionError: the repo is gated and needs authentication. 

562 RuntimeError: the listing could not be fetched. 

563 """ 

564 try: 

565 resp = httpx.get( 

566 f"{HF_API_URL}/{hf_repo}", 

567 timeout=DEFAULT_TIMEOUT, 

568 headers=hf_headers(), 

569 ) 

570 if resp.status_code == HTTPStatus.UNAUTHORIZED: 

571 raise PermissionError( 

572 f"{hf_repo} requires HuggingFace authentication. " 

573 "Set HF_TOKEN env var or visit the repo page to request access." 

574 ) 

575 resp.raise_for_status() 

576 siblings = resp.json().get("siblings", []) 

577 except PermissionError: 

578 raise 

579 except Exception as exc: 

580 raise RuntimeError(f"Cannot query files for {hf_repo}: {exc}") from exc 

581 return [s.get("rfilename", "") for s in siblings] 

582 

583 

584def _mmproj_rank(filename: str) -> tuple[bool, str]: 

585 """Sort key preferring an unquantized projector, ties broken by name.""" 

586 return (quant_label(filename) not in FLOAT_QUANTS, filename) 

587 

588 

589def _resolve_mmproj_filename(hf_repo: str, pattern: str) -> str | None: 

590 """Resolve an mmproj filename pattern to a concrete filename via the HF API.""" 

591 if WILDCARD not in pattern: 

592 return pattern 

593 try: 

594 names = _repo_sibling_files(hf_repo) 

595 except (PermissionError, RuntimeError) as exc: 

596 log.warning("Cannot query mmproj files for %s: %s", hf_repo, exc) 

597 return None 

598 matches = [name for name in names if fnmatch.fnmatch(name, pattern)] 

599 return min(matches, key=_mmproj_rank) if matches else None 

600 

601 

602def resolve_filename(entry: CatalogModel, *, can_load: LoadCheck | None = None) -> str: 

603 """The repo file a pull of *entry* fetches, gated on each candidate's GGUF header. 

604 

605 Quant labels only order the candidates. A file whose header calls it a 

606 projector or an adapter is never the model, so a repo that labels its 

607 projector ``Q8_0`` cannot install it as one. 

608 

609 *can_load* is the engine's verdict on one file, supplied by the layer that 

610 owns the engine. It runs last because it is the expensive question, and it 

611 decides between candidates rather than judging the one already chosen: a 

612 repo publishing the same weights in several packings holds files the engine 

613 reads and files it cannot, and only one of them is worth downloading. 

614 

615 Where no architecture is supported the best-ranked weights still come back. 

616 Refusing here would report a generic error and disable ``--allow-unsupported``, 

617 so that verdict belongs to the architecture guard. 

618 

619 Raises: 

620 PermissionError: the repo is gated and needs authentication. 

621 UnsupportedQuantError: every candidate carries weights the engine cannot 

622 decode; the first such refusal is re-raised, naming its file. 

623 RuntimeError: the repo listing failed, or it holds no model weights. 

624 """ 

625 named = entry.gguf_filename 

626 if WILDCARD not in named and file_header(entry.hf_repo, named).is_model: 

627 return named 

628 unsupported: str | None = None 

629 refused: UnsupportedQuantError | None = None 

630 for candidate in rank_gguf_candidates(_repo_sibling_files(entry.hf_repo)): 

631 header = file_header(entry.hf_repo, candidate) 

632 if not header.is_model: 

633 continue 

634 if classify(header.architecture) is ModelCompat.UNSUPPORTED: 

635 unsupported = unsupported or candidate 

636 continue 

637 if can_load is not None: 

638 try: 

639 can_load(entry.hf_repo, candidate) 

640 except UnsupportedQuantError as exc: 

641 refused = refused or exc 

642 continue 

643 return candidate 

644 if unsupported is not None: 

645 return unsupported 

646 if refused is not None: 

647 raise refused 

648 raise RuntimeError(f"No GGUF model weights found in {entry.hf_repo}") 

649 

650 

651_SIZE_UNKNOWN = 0 

652 

653 

654def _cached_file_is_complete(hf_repo: str, filename: str, dest: Path) -> bool: 

655 """Decide whether an existing cached file may be accepted as complete. 

656 

657 Verifies the on-disk byte size against the size HuggingFace reports for 

658 *filename*. A mismatch means a truncated / corrupt download, so the file 

659 is rejected and re-fetched. When the size can't be fetched (offline, API 

660 error) it stays unknown and the cached file is accepted: there's nothing 

661 to verify against and refusing would block all offline reuse. 

662 """ 

663 expected = fetch_expected_file_size(hf_repo, filename) 

664 if expected == _SIZE_UNKNOWN: 

665 return True 

666 actual = dest.stat().st_size 

667 if actual == expected: 

668 return True 

669 log.warning( 

670 "Cached %s is %d bytes but HuggingFace reports %d; re-downloading", 

671 dest, 

672 actual, 

673 expected, 

674 ) 

675 return False 

676 

677 

678def _hf_file_size(hf_repo: str, filename: str) -> int | None: 

679 """Byte size huggingface_hub resolves for *filename* (None if unreported).""" 

680 from huggingface_hub import get_hf_file_metadata, hf_hub_url 

681 

682 return get_hf_file_metadata(hf_hub_url(hf_repo, filename), token=hf_token()).size 

683 

684 

685def _missing_file_message(hf_repo: str, filename: str) -> str: 

686 """User-facing error for a file the Hub reports as nonexistent.""" 

687 return ( 

688 f"File {filename!r} does not exist in {hf_repo} on HuggingFace. " 

689 "Check the filename on the repo page." 

690 ) 

691 

692 

693def fetch_expected_file_size(hf_repo: str, filename: str) -> int: 

694 """Return the byte size huggingface_hub reports for *filename*, or _SIZE_UNKNOWN. 

695 

696 Resolves via hf_hub's own file metadata (correct revision, redirects, and 

697 LFS/Xet handled uniformly) instead of scraping the repo tree. Returns 0 when 

698 offline or unresolvable, in which case the caller keeps the cached file. A 

699 file the Hub reports as nonexistent raises instead: that answer is 

700 definitive, and treating it as unknown let a pull of a mistyped filename 

701 accept a stale local file and report success without downloading anything. 

702 """ 

703 from huggingface_hub.errors import RemoteEntryNotFoundError 

704 

705 try: 

706 return _hf_file_size(hf_repo, filename) or _SIZE_UNKNOWN 

707 except RemoteEntryNotFoundError: 

708 raise RuntimeError(_missing_file_message(hf_repo, filename)) from None 

709 except Exception: 

710 return _SIZE_UNKNOWN