Coverage for src/lilbee/modelhub/registry.py: 100%

397 statements  

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

1"""Manifest store keyed by ``(hf_repo, gguf_filename)`` over the HF cache. 

2 

3Canonical ref: ``<hf_repo>/<gguf_filename>``. Two quants of the same 

4repo are two distinct installations. Manifests live at 

5``manifests/<repo--repo>/<filename>.json``; blobs at 

6``models--<repo--repo>/blobs/<sha>``. 

7""" 

8 

9from __future__ import annotations 

10 

11import contextlib 

12import hashlib 

13import json 

14import logging 

15import os 

16import re 

17import shutil 

18import tempfile 

19from dataclasses import asdict, dataclass, field 

20from datetime import UTC, datetime 

21from pathlib import Path 

22from typing import TYPE_CHECKING 

23 

24from lilbee.catalog.query import reclassify_by_name 

25from lilbee.catalog.refs import ( 

26 GGUF_SUFFIX, 

27 NATIVE_GGUF_REF_MIN_SLASHES, 

28 format_native_gguf_ref, 

29 is_bare_hf_repo, 

30 split_shard_filenames, 

31) 

32from lilbee.catalog.types import ModelTask 

33from lilbee.core.config.model import cfg 

34from lilbee.core.security import validate_path_within 

35from lilbee.providers import gguf_meta 

36from lilbee.providers.base import ProviderError 

37 

38if TYPE_CHECKING: 

39 from lilbee.catalog.models import CatalogModel 

40 

41log = logging.getLogger(__name__) 

42 

43_HASH_CHUNK_SIZE = 8192 # bytes read per iteration when hashing 

44_REPO_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$") 

45# A GGUF filename, optionally under repo subdirectories (unsloth stores quants 

46# in e.g. ``Q4_K_M/Model-...-00001-of-00003.gguf``). Path separators are allowed; 

47# ``..`` and absolute paths are rejected in the validator to stay inside the repo. 

48_FILENAME_RE = re.compile(r"^[a-zA-Z0-9._/-]+\.gguf$") 

49 

50REPO_DIR_SEPARATOR = "--" 

51_MANIFEST_SUFFIX = f"{GGUF_SUFFIX}.json" 

52 

53 

54def _validate_hf_repo(hf_repo: str) -> str: 

55 """Validate that a HuggingFace repo id has the form ``org/name``.""" 

56 if not hf_repo or not _REPO_SEGMENT_RE.match(hf_repo) or ".." in hf_repo: 

57 raise ValueError(f"Invalid hf_repo: {hf_repo!r}") 

58 return hf_repo 

59 

60 

61def _validate_gguf_filename(filename: str) -> str: 

62 """Validate a ``.gguf`` filename, allowing repo subdirectories but no traversal.""" 

63 if ( 

64 not filename 

65 or not _FILENAME_RE.match(filename) 

66 or ".." in filename 

67 or filename.startswith("/") 

68 ): 

69 raise ValueError(f"Invalid gguf_filename: {filename!r}") 

70 return filename 

71 

72 

73_REF_SHAPE_HINT = "Use '<org>/<repo>/<filename>.gguf'." 

74 

75 

76def parse_hf_ref(ref: str) -> tuple[str, str]: 

77 """Split ``<org>/<repo>/<file>.gguf`` into ``(hf_repo, gguf_filename)``. 

78 

79 The repo is always the first two segments (``<org>/<repo>``); everything 

80 after is the filename, which may include repo subdirectories (unsloth stores 

81 quants under e.g. ``Q4_K_M/Model-...-00001-of-00003.gguf``). 

82 """ 

83 if not ref.endswith(GGUF_SUFFIX) or ref.count("/") < NATIVE_GGUF_REF_MIN_SLASHES: 

84 raise ValueError(f"Model ref {ref!r} is not a HuggingFace ref. {_REF_SHAPE_HINT}") 

85 parts = ref.split("/") 

86 hf_repo = "/".join(parts[:NATIVE_GGUF_REF_MIN_SLASHES]) 

87 gguf_filename = "/".join(parts[NATIVE_GGUF_REF_MIN_SLASHES:]) 

88 return _validate_hf_repo(hf_repo), _validate_gguf_filename(gguf_filename) 

89 

90 

91def repo_to_dir(hf_repo: str) -> str: 

92 """Encode an HF repo for use as a directory name (HF cache convention).""" 

93 return hf_repo.replace("/", REPO_DIR_SEPARATOR) 

94 

95 

96@dataclass 

97class ModelManifest: 

98 """One installed model's metadata. Identity: ``(hf_repo, gguf_filename)``.""" 

99 

100 hf_repo: str 

101 gguf_filename: str 

102 size_bytes: int # primary (first-shard) blob size; validated against the blob on disk 

103 task: ModelTask 

104 downloaded_at: str # ISO 8601 

105 blob: str | None = None # SHA-256 hex of the blob in the HF cache; None pre-install 

106 # A split GGUF has further shard blobs beyond ``blob``. ``total_size_bytes`` 

107 # is the sum across every shard (None = single file, use ``size_bytes``); 

108 # ``shard_blobs`` are the non-primary shard digests, so removal frees them all. 

109 total_size_bytes: int | None = None 

110 shard_blobs: list[str] = field(default_factory=list) 

111 

112 @property 

113 def ref(self) -> str: 

114 return format_native_gguf_ref(self.hf_repo, self.gguf_filename) 

115 

116 @property 

117 def disk_size_bytes(self) -> int: 

118 """Total bytes this model occupies on disk, across all shards.""" 

119 return self.total_size_bytes if self.total_size_bytes is not None else self.size_bytes 

120 

121 

122def _copy_atomic(source_path: Path, blob_path: Path) -> None: 

123 """Copy *source_path* to *blob_path* via a temp file + atomic rename. 

124 

125 A crash mid-copy leaves only the temp file, never a partial blob at 

126 the final digest path that callers would treat as complete. 

127 """ 

128 fd, tmp_name = tempfile.mkstemp(dir=str(blob_path.parent), suffix=".part") 

129 tmp_path = Path(tmp_name) 

130 try: 

131 with os.fdopen(fd, "wb") as dst, source_path.open("rb") as src: 

132 shutil.copyfileobj(src, dst) 

133 os.replace(tmp_path, blob_path) 

134 except BaseException: 

135 tmp_path.unlink(missing_ok=True) 

136 raise 

137 

138 

139def _blob_size_matches(blob_file: Path, expected_size: int) -> bool: 

140 """True iff *blob_file* exists and its byte size equals *expected_size*. 

141 

142 A blob shorter than the manifest's recorded size is a truncated / 

143 interrupted download and must not count as installed. 

144 """ 

145 try: 

146 return blob_file.stat().st_size == expected_size 

147 except OSError: 

148 return False 

149 

150 

151def _sha256_file(path: Path) -> str: 

152 """Compute SHA-256 hex digest of a file.""" 

153 h = hashlib.sha256() 

154 with path.open("rb") as f: 

155 while True: 

156 chunk = f.read(_HASH_CHUNK_SIZE) 

157 if not chunk: 

158 break 

159 h.update(chunk) 

160 return h.hexdigest() 

161 

162 

163_SHA256_HEX = re.compile(r"[0-9a-f]{64}") 

164_BLOB_SHARD_LEN = 2 

165 

166 

167def _blob_digest(source_path: Path) -> str: 

168 """Digest for *source_path*, reusing the HF-cache blob name when possible. 

169 

170 huggingface_hub names cache blobs by their sha256, so a snapshot path that 

171 resolves into a ``blobs/`` dir already carries its digest. Re-hashing a 

172 multi-GB GGUF only to recompute that name is slow and, on network volumes, 

173 I/O-fragile enough to fail registration outright. Plain files still hash. 

174 """ 

175 real = source_path.resolve() 

176 name = _cache_blob_name(real) 

177 if name is not None and _SHA256_HEX.fullmatch(real.name): 

178 return name 

179 return _sha256_file(source_path) 

180 

181 

182def _cache_blob_name(real: Path) -> str | None: 

183 """Name of *real* relative to its cache ``blobs/`` dir, or None outside one. 

184 

185 huggingface_hub writes flat blobs (``blobs/<sha>``), and its shared blob 

186 store shards them under a two-character prefix (``blobs/a0/<sha>``). The name 

187 keeps that prefix, so ``cache_path / "blobs" / name`` finds the file in both. 

188 """ 

189 if real.parent.name == "blobs": 

190 return real.name 

191 if real.parent.parent.name == "blobs" and len(real.parent.name) == _BLOB_SHARD_LEN: 

192 return f"{real.parent.name}/{real.name}" 

193 return None 

194 

195 

196def _reraise_walk_error(error: OSError) -> None: 

197 """Re-raise a directory-read fault; a directory that vanished is not one.""" 

198 if not isinstance(error, FileNotFoundError): 

199 raise error 

200 

201 

202def _manifest_files(repo_dir: Path) -> list[Path]: 

203 """Sorted manifest paths under *repo_dir*, quant subdirectories included. 

204 

205 Raises ``OSError`` when a directory under *repo_dir* cannot be read, so an 

206 unreadable tree never reads as an empty one. 

207 """ 

208 found: list[Path] = [] 

209 for dirpath, _dirnames, filenames in os.walk(repo_dir, onerror=_reraise_walk_error): 

210 found.extend(Path(dirpath) / name for name in filenames if name.endswith(_MANIFEST_SUFFIX)) 

211 return sorted(found) 

212 

213 

214class ModelRegistry: 

215 """Read/write manifests and resolve refs to blobs in the HF cache.""" 

216 

217 def __init__(self, models_dir: Path) -> None: 

218 self._root = models_dir 

219 self._manifests_dir = models_dir / "manifests" 

220 

221 def _repo_cache_dir(self, hf_repo: str) -> Path: 

222 """The HuggingFace cache directory for *hf_repo* under this registry root.""" 

223 return self._root / f"models--{repo_to_dir(hf_repo)}" 

224 

225 def resolve(self, ref: str) -> Path: 

226 """Return the loadable GGUF path for *ref*; ``KeyError`` if not installed. 

227 

228 A single-file GGUF resolves to its content-hashed blob (the snapshot 

229 file on a cache without symlinks); a split GGUF to its first shard's 

230 snapshot symlink (so llama.cpp finds the sibling shards). 

231 

232 The canonical *ref* is ``<org>/<repo>/<file>.gguf`` resolved via the 

233 lilbee manifest. Two other shapes are accepted as a backwards-compat 

234 concession for builds already published (whose on-disk layout differs), 

235 not as the intended contract: a bare ``<org>/<repo>`` (older builds 

236 persisted these into ``config.toml``) resolves to the one quant of that 

237 repo that's installed, and a manifest that's missing / unparseable / 

238 blob-less falls back to whatever GGUF ``huggingface_hub`` reports the 

239 cache holds for that ref. The HF cache layout is stable, so this lets an 

240 upgrade keep working without anyone purging their lilbee data dir; it is 

241 deliberately the exception here, not a pattern to follow elsewhere. 

242 """ 

243 if is_bare_hf_repo(ref): 

244 return self._resolve_repo_only(_validate_hf_repo(ref)) 

245 hf_repo, gguf_filename = parse_hf_ref(ref) 

246 shards = split_shard_filenames(gguf_filename) 

247 if len(shards) > 1: 

248 return self._resolve_split(ref, hf_repo, shards) 

249 manifest = self._read_manifest(hf_repo, gguf_filename) 

250 if manifest is not None: 

251 backing = self._manifest_backing_file(manifest) 

252 if backing is not None: 

253 return backing 

254 recovered = self._find_cached_gguf(hf_repo, gguf_filename) 

255 if recovered is not None: 

256 self._reregister_from_cache(hf_repo, gguf_filename, recovered) 

257 return recovered 

258 if manifest is None: 

259 raise KeyError(f"Model {ref} not installed") 

260 # Manifest present but neither it nor the cache yields a blob; keep the 

261 # specific diagnostic so a corrupted cache stays debuggable. 

262 cache_path = self._repo_cache_dir(manifest.hf_repo) 

263 if not cache_path.exists(): 

264 raise KeyError(f"Cache folder missing for {ref}: {cache_path.name}") 

265 if manifest.blob is None: 

266 raise KeyError(f"Manifest for {ref} has no blob hash; install incomplete") 

267 blob_file = cache_path / "blobs" / manifest.blob 

268 if blob_file.exists(): 

269 raise KeyError( 

270 f"Blob for {ref} is truncated: {blob_file.stat().st_size} of " 

271 f"{manifest.size_bytes} bytes; re-download required" 

272 ) 

273 raise KeyError(f"Blob file missing for {ref}: {manifest.blob}") 

274 

275 def _resolve_split(self, ref: str, hf_repo: str, shards: list[str]) -> Path: 

276 """Resolve a split GGUF to its first shard's snapshot symlink. 

277 

278 llama.cpp loads the whole set from the first shard, locating the siblings 

279 by filename next to it. Only the snapshot dir co-locates the shards under 

280 their real names (the blobs dir names them by hash), so hand back the 

281 symlink, not the blob. Every shard must be present first: the first shard 

282 alone used to read as installed, registering an unloadable model that a 

283 re-pull then skipped. 

284 """ 

285 if not self._split_shards_present(hf_repo, shards[0]): 

286 raise KeyError(f"Split GGUF {ref} is missing shards; re-pull to fetch the full set") 

287 first_shard = self._snapshot_gguf_path(hf_repo, shards[0]) 

288 if first_shard is None: 

289 raise KeyError(f"Model {ref} not installed") 

290 if self._read_manifest(hf_repo, shards[0]) is None: 

291 # Same cache recovery as the single-file path; resolve the symlink so 

292 # the manifest records the content-hashed blob, not the link, and pass 

293 # the snapshot path so the shard accounting is recovered too. 

294 self._reregister_from_cache( 

295 hf_repo, shards[0], first_shard.resolve(), snapshot_path=first_shard 

296 ) 

297 return first_shard 

298 

299 def _resolve_repo_only(self, hf_repo: str) -> Path: 

300 """Resolve a bare ``<org>/<repo>`` ref to the GGUF of that repo on disk. 

301 

302 Older builds persisted bare repo refs for the chat / embedding model. 

303 Prefers a current-format manifest under the repo; otherwise asks 

304 ``huggingface_hub`` what GGUFs the cache holds for the repo and returns 

305 the first one (alphabetical for determinism if more than one quant is 

306 installed). 

307 """ 

308 manifest_dir = self._manifests_dir / repo_to_dir(hf_repo) 

309 if manifest_dir.is_dir(): 

310 for mf in _manifest_files(manifest_dir): 

311 manifest = self._load_manifest_file(mf) 

312 if manifest is None: 

313 continue 

314 backing = self._manifest_backing_file(manifest) 

315 if backing is not None: 

316 return backing 

317 for filename in sorted(self._cached_gguf_names(hf_repo)): 

318 shards = split_shard_filenames(filename) 

319 if len(shards) > 1: 

320 # A split set in the cache: skip its non-first shards and resolve 

321 # the whole set from shard 1 so we hand back the snapshot symlink 

322 # (siblings co-located, loadable) with shard accounting, not 

323 # shard 1's blob as an unloadable single file. 

324 if filename != shards[0]: 

325 continue 

326 with contextlib.suppress(KeyError): 

327 return self._resolve_split( 

328 format_native_gguf_ref(hf_repo, filename), hf_repo, shards 

329 ) 

330 continue 

331 recovered = self._find_cached_gguf(hf_repo, filename) 

332 if recovered is not None: 

333 self._reregister_from_cache(hf_repo, filename, recovered) 

334 return recovered 

335 raise KeyError(f"Model {hf_repo} not installed") 

336 

337 def _cached_gguf_names(self, hf_repo: str) -> set[str]: 

338 """``.gguf`` filenames the HuggingFace cache holds for *hf_repo*.""" 

339 if not self._root.is_dir(): 

340 return set() 

341 from huggingface_hub import scan_cache_dir 

342 

343 info = scan_cache_dir(self._root) 

344 return { 

345 f.file_name 

346 for repo in info.repos 

347 if repo.repo_id == hf_repo 

348 for rev in repo.revisions 

349 for f in rev.files 

350 if f.file_name.endswith(GGUF_SUFFIX) 

351 } 

352 

353 def _snapshot_gguf_path(self, hf_repo: str, gguf_filename: str) -> Path | None: 

354 """Return the snapshot *symlink* path for a cached GGUF, or None. 

355 

356 Returns the symlink, not the blob, so a split GGUF loads from a dir where 

357 its sibling shards are co-located under their real names. 

358 """ 

359 from huggingface_hub import try_to_load_from_cache 

360 

361 hit = try_to_load_from_cache( 

362 repo_id=hf_repo, filename=gguf_filename, cache_dir=str(self._root) 

363 ) 

364 candidate: Path | None = None 

365 if isinstance(hit, str): # exact repo-relative match 

366 candidate = Path(hit) 

367 else: # None or the _CACHED_NO_EXIST sentinel: locate the basename instead 

368 snapshots = self._repo_cache_dir(hf_repo) / "snapshots" 

369 if snapshots.is_dir(): 

370 basename = Path(gguf_filename).name 

371 # Several cached revisions can hold the basename; prefer the most 

372 # recently materialized one over an arbitrary lexicographic pick. 

373 candidate = max( 

374 snapshots.rglob(basename), key=lambda p: p.lstat().st_mtime, default=None 

375 ) 

376 if candidate is None: 

377 return None 

378 try: 

379 validate_path_within(candidate.resolve(), self._root) 

380 except ValueError: 

381 return None 

382 return candidate 

383 

384 def _find_cached_gguf(self, hf_repo: str, gguf_filename: str) -> Path | None: 

385 """Return the cached blob path for ``hf_repo``/``gguf_filename``, or None. 

386 

387 Locates the snapshot symlink (subdir-aware) and resolves it to its blob, 

388 bounded to the cache directory. 

389 """ 

390 symlink = self._snapshot_gguf_path(hf_repo, gguf_filename) 

391 return symlink.resolve() if symlink is not None else None 

392 

393 def _split_shards_present(self, hf_repo: str, gguf_filename: str) -> bool: 

394 """True unless *gguf_filename* is a split GGUF missing one of its shards. 

395 

396 A single-file GGUF is always present here. For a split set 

397 (``<base>-0000N-of-0000M.gguf``) every shard must be cached, since 

398 llama.cpp loads the whole set from the first shard but needs them all. 

399 """ 

400 shards = split_shard_filenames(gguf_filename) 

401 if len(shards) == 1: 

402 return True 

403 return all(self._find_cached_gguf(hf_repo, shard) is not None for shard in shards) 

404 

405 def shard_paths(self, ref: str) -> list[Path]: 

406 """On-disk paths of *ref*'s GGUF shards that exist next to its resolved path. 

407 

408 A split GGUF resolves to its first shard's snapshot symlink with the 

409 siblings co-located, so every shard is returned; a single-file GGUF 

410 resolves to its content-hashed blob, where no sibling exists under the 

411 real filename. Raises ``KeyError`` / ``ValueError`` like :meth:`resolve`. 

412 """ 

413 first = self.resolve(ref) 

414 _repo, filename = parse_hf_ref(ref) 

415 candidates = ( 

416 first.parent / Path(shard).name for shard in split_shard_filenames(Path(filename).name) 

417 ) 

418 return [path for path in candidates if path.exists()] 

419 

420 def _reregister_from_cache( 

421 self, 

422 hf_repo: str, 

423 gguf_filename: str, 

424 blob_path: Path, 

425 snapshot_path: Path | None = None, 

426 ) -> None: 

427 """Best-effort manifest write for a cache-recovered model so listings see it. 

428 

429 *snapshot_path* is the first shard's snapshot path (siblings co-located); 

430 when given, the split-shard accounting is recovered too, so a cache-only 

431 split GGUF still frees every shard and reports its full size. 

432 """ 

433 ref = format_native_gguf_ref(hf_repo, gguf_filename) 

434 try: 

435 task = ModelTask(reclassify_by_name(ref, ModelTask.CHAT)) 

436 total_size, shard_blobs = ( 

437 _shard_accounting(snapshot_path) if snapshot_path is not None else (None, []) 

438 ) 

439 self._write_manifest( 

440 ModelManifest( 

441 hf_repo=hf_repo, 

442 gguf_filename=gguf_filename, 

443 size_bytes=blob_path.stat().st_size, 

444 task=task, 

445 downloaded_at=datetime.now(UTC).isoformat(), 

446 # Only a file under blobs/ is named by its sha; a symlink-less 

447 # snapshot file is not, so no digest is recorded for it. 

448 blob=_cache_blob_name(blob_path), 

449 total_size_bytes=total_size, 

450 shard_blobs=shard_blobs, 

451 ) 

452 ) 

453 log.info("Recovered manifest for %s from the model cache", ref) 

454 except Exception: # cache-warming write; the resolve already returned a path 

455 log.debug("Could not re-register %s from the model cache", ref, exc_info=True) 

456 

457 def has_vision_projector(self, ref: str) -> bool: 

458 """True when *ref* is installed with an mmproj projector beside its weights. 

459 

460 A vision model is a chat model with a projector, so the manifest task 

461 cannot tell the two apart. The vision picker and the vision-role check 

462 both ask this question. The lookup walks the HF cache layout, so it 

463 finds the projector from a content-hashed blob path too. 

464 """ 

465 try: 

466 gguf_meta.find_mmproj_for_model(self.resolve(ref)) 

467 except (KeyError, ValueError, ProviderError): 

468 return False 

469 return True 

470 

471 def is_installed(self, ref: str) -> bool: 

472 """Return True if a model is installed and its blob is present.""" 

473 try: 

474 self.resolve(ref) 

475 return True 

476 except (KeyError, ValueError): 

477 return False 

478 

479 def has_manifest(self, ref: str) -> bool: 

480 """True when a manifest, not only a cached blob, records *ref*. 

481 

482 ``is_installed`` also answers True for a GGUF found in the HF cache, 

483 which the model list does not show until a manifest exists. 

484 """ 

485 if is_bare_hf_repo(ref): 

486 return self.installed_ref_for_repo(ref) is not None 

487 return self.get_manifest(ref) is not None 

488 

489 def install( 

490 self, 

491 hf_repo: str, 

492 gguf_filename: str, 

493 source_path: Path, 

494 manifest: ModelManifest, 

495 ) -> Path: 

496 """Write a manifest, copying *source_path* into the HF cache if needed.""" 

497 digest = _blob_digest(source_path) 

498 cache_path = self._repo_cache_dir(hf_repo) 

499 blobs_dir = cache_path / "blobs" 

500 blob_path = blobs_dir / digest 

501 if not blob_path.exists(): 

502 blob_path.parent.mkdir(parents=True, exist_ok=True) 

503 _copy_atomic(source_path, blob_path) 

504 

505 updated = ModelManifest( 

506 hf_repo=hf_repo, 

507 gguf_filename=gguf_filename, 

508 # Record the size install actually wrote, not the caller's claim, 

509 # so the on-disk size check has a trustworthy reference. 

510 size_bytes=source_path.stat().st_size, 

511 task=manifest.task, 

512 downloaded_at=manifest.downloaded_at, 

513 blob=digest, 

514 # Carry the split-shard accounting through unchanged (computed by the 

515 # caller from the full shard set); install only rewrites the primary. 

516 total_size_bytes=manifest.total_size_bytes, 

517 shard_blobs=manifest.shard_blobs, 

518 ) 

519 self._write_manifest(updated) 

520 return blob_path 

521 

522 def remove(self, ref: str) -> bool: 

523 """Remove a manifest and its backing blob. 

524 

525 The blob is shared via SHA-256 digest, so it only goes away 

526 when no other installed manifest references the same digest. 

527 Empty cache directories (``blobs/``, the per-repo ``models--`` 

528 folder, and the per-repo manifest folder) are pruned so a 

529 deleted model leaves no orphan bytes behind. 

530 """ 

531 try: 

532 hf_repo, gguf_filename = parse_hf_ref(ref) 

533 except ValueError: 

534 return False 

535 manifest = self._read_manifest(hf_repo, gguf_filename) 

536 if manifest is None: 

537 return False 

538 # Manifests written before shard accounting existed have no shard_blobs, so 

539 # recover them from the cache *before* unlinking (resolve needs the manifest). 

540 shard_blobs = manifest.shard_blobs or self._recover_legacy_shard_blobs(ref) 

541 manifest_path = self._manifest_path(hf_repo, gguf_filename) 

542 manifest_path.unlink() 

543 repo_dir = manifest_path.parent 

544 if repo_dir.exists() and not any(repo_dir.iterdir()): 

545 repo_dir.rmdir() 

546 self._unlink_snapshot_entries(manifest) 

547 # Free the primary blob and every extra shard blob; a split GGUF has more 

548 # than one, and leaving the others orphans them when a sibling quant keeps 

549 # the repo cache dir alive. The surviving manifests for this repo are read 

550 # once here rather than per digest (list_installed walks the whole tree). 

551 # A symlink-less recovery records no digest; GC still runs once so an 

552 # unused repo cache dir is pruned. 

553 siblings = [m for m in self.list_installed() if m.hf_repo == manifest.hf_repo] 

554 digests: list[str | None] = [d for d in [manifest.blob, *shard_blobs] if d is not None] 

555 for digest in digests or [None]: 

556 self._gc_blob(manifest.hf_repo, digest, siblings=siblings) 

557 log.info("Removed model %s", ref) 

558 return True 

559 

560 def _unlink_snapshot_entries(self, manifest: ModelManifest) -> None: 

561 """Drop the snapshot entries for *manifest*'s shards. 

562 

563 On a symlink-less cache they hold the bytes and would resurrect the 

564 model via cache recovery; on a symlinked cache they would dangle once 

565 the blob is gc'd. 

566 """ 

567 for shard in split_shard_filenames(manifest.gguf_filename): 

568 snapshot_entry = self._snapshot_gguf_path(manifest.hf_repo, shard) 

569 if snapshot_entry is not None: 

570 snapshot_entry.unlink(missing_ok=True) 

571 

572 def _recover_legacy_shard_blobs(self, ref: str) -> list[str]: 

573 """Extra shard blob digests for a pre-accounting split GGUF, best-effort. 

574 

575 Older manifests recorded only the first shard, so removing them would 

576 orphan the rest. Derive the sibling shards from the cache; empty on any 

577 failure or for a single-file model, so removal never breaks. 

578 """ 

579 with contextlib.suppress(Exception): 

580 shards = self.shard_paths(ref) 

581 return [_blob_digest(path) for path in shards[1:]] 

582 return [] 

583 

584 def _gc_blob( 

585 self, hf_repo: str, digest: str | None, *, siblings: list[ModelManifest] | None = None 

586 ) -> None: 

587 """Drop blob bytes and HuggingFace cache cruft now that *digest* 

588 and possibly the whole repo are unused. A None *digest* (a manifest 

589 recovered on a symlink-less cache records no digest) only prunes the 

590 repo dir when no installed manifest is left. 

591 

592 When the per-repo ``models--<repo>/`` directory has no installed 

593 manifests left, the whole directory is wiped so HF's ``refs/``, 

594 ``snapshots/``, and stale ``blobs/`` all go with it. Otherwise 

595 only the specific blob file is removed when no remaining 

596 manifest still references its digest. 

597 

598 ``siblings`` is the surviving-manifest list for *hf_repo*; callers 

599 freeing several blobs at once pass it in so the manifest tree is walked 

600 once instead of per digest. Defaults to reading it when omitted. 

601 """ 

602 cache_path = self._repo_cache_dir(hf_repo) 

603 try: 

604 validate_path_within(cache_path, self._root) 

605 except ValueError: 

606 log.warning("Refusing to remove cache outside models_dir: %s", cache_path) 

607 return 

608 if siblings is None: 

609 siblings = [m for m in self.list_installed() if m.hf_repo == hf_repo] 

610 if not siblings: 

611 if cache_path.exists(): 

612 shutil.rmtree(cache_path) 

613 return 

614 if digest is None: # no digest recorded (symlink-less recovery): dir-prune only 

615 return 

616 if any(digest == m.blob or digest in m.shard_blobs for m in siblings): 

617 return 

618 blob_file = cache_path / "blobs" / digest 

619 try: 

620 validate_path_within(blob_file, self._root) 

621 except ValueError: 

622 log.warning("Refusing to remove blob outside models_dir: %s", blob_file) 

623 return 

624 if blob_file.exists(): 

625 blob_file.unlink() 

626 

627 def list_installed(self) -> list[ModelManifest]: 

628 """Return manifests for models whose blob is fully present on disk. 

629 

630 A manifest with a null blob field or a missing blob file is the 

631 residue of a canceled or partial download. Surfacing it would 

632 let the picker offer an unusable selection, so the read filter 

633 lives here at the source instead of in every UI caller. 

634 

635 An absent manifest tree is an empty list. A tree that is present but 

636 cannot be read raises ``OSError``, because "unknown" is not "none". 

637 """ 

638 manifests: list[ModelManifest] = [] 

639 try: 

640 repo_dirs = sorted(self._manifests_dir.iterdir()) 

641 except FileNotFoundError: 

642 return manifests 

643 for repo_dir in repo_dirs: 

644 if not repo_dir.is_dir(): 

645 continue 

646 for tag_file in _manifest_files(repo_dir): 

647 manifest = self._load_manifest_file(tag_file) 

648 if manifest is not None and self._blob_present(manifest): 

649 manifests.append(manifest) 

650 return manifests 

651 

652 def _blob_present(self, manifest: ModelManifest) -> bool: 

653 """True iff *manifest*'s bytes are on disk with a matching size.""" 

654 return self._manifest_backing_file(manifest) is not None 

655 

656 def _manifest_backing_file(self, manifest: ModelManifest) -> Path | None: 

657 """The on-disk file holding *manifest*'s bytes, or None if absent/truncated. 

658 

659 Normally the content-hashed blob; on a cache without symlinks (the 

660 Windows default) the bytes live at the snapshot path, so that is the 

661 fallback. ``resolve`` and ``list_installed`` must both gate on this 

662 one predicate, or installed-ness disagrees between ``pull`` and ``ask``. 

663 """ 

664 if manifest.blob is not None: 

665 blob_file = self._repo_cache_dir(manifest.hf_repo) / "blobs" / manifest.blob 

666 if _blob_size_matches(blob_file, manifest.size_bytes): 

667 return blob_file 

668 recovered = self._find_cached_gguf(manifest.hf_repo, manifest.gguf_filename) 

669 if recovered is not None and _blob_size_matches(recovered, manifest.size_bytes): 

670 return recovered 

671 return None 

672 

673 def get_manifest(self, ref: str) -> ModelManifest | None: 

674 """Return the manifest for *ref* or None if not installed.""" 

675 try: 

676 hf_repo, gguf_filename = parse_hf_ref(ref) 

677 except ValueError: 

678 return None 

679 return self._read_manifest(hf_repo, gguf_filename) 

680 

681 def installed_ref_for_repo(self, hf_repo: str) -> str | None: 

682 """Full ``<repo>/<file>.gguf`` ref of an installed quant of *hf_repo*, or None. 

683 

684 Alphabetical-first when several quants are installed, matching 

685 ``_resolve_repo_only``'s determinism. 

686 """ 

687 refs = sorted(m.ref for m in self.list_installed() if m.hf_repo == hf_repo) 

688 return refs[0] if refs else None 

689 

690 def _manifest_path(self, hf_repo: str, gguf_filename: str) -> Path: 

691 repo = _validate_hf_repo(hf_repo) 

692 filename = _validate_gguf_filename(gguf_filename) 

693 path = self._manifests_dir / repo_to_dir(repo) / f"{filename}.json" 

694 validate_path_within(path, self._manifests_dir) 

695 return path 

696 

697 def _read_manifest(self, hf_repo: str, gguf_filename: str) -> ModelManifest | None: 

698 return self._load_manifest_file(self._manifest_path(hf_repo, gguf_filename)) 

699 

700 def _write_manifest(self, manifest: ModelManifest) -> None: 

701 path = self._manifest_path(manifest.hf_repo, manifest.gguf_filename) 

702 path.parent.mkdir(parents=True, exist_ok=True) 

703 data = json.dumps(asdict(manifest), indent=2) 

704 tmp_path: str | None = None 

705 try: 

706 with tempfile.NamedTemporaryFile( 

707 dir=path.parent, suffix=".tmp", mode="w", encoding="utf-8", delete=False 

708 ) as tmp: 

709 tmp_path = tmp.name 

710 tmp.write(data) 

711 os.replace(tmp_path, path) 

712 except BaseException: 

713 if tmp_path is not None: 

714 Path(tmp_path).unlink(missing_ok=True) 

715 raise 

716 

717 def _load_manifest_file(self, path: Path) -> ModelManifest | None: 

718 if not path.exists(): 

719 return None 

720 try: 

721 data = json.loads(path.read_text(encoding="utf-8")) 

722 return ModelManifest(**data) 

723 # UnicodeDecodeError is a ValueError, not a JSONDecodeError. 

724 except (json.JSONDecodeError, UnicodeDecodeError, TypeError, KeyError): 

725 log.warning("Corrupt manifest: %s", path) 

726 return None 

727 

728 

729_HF_SNAPSHOTS_DIR = "snapshots" 

730 

731 

732def _repo_relative_gguf_name(file_path: Path) -> str: 

733 """Recover the repo-relative GGUF filename, keeping any subdir prefix. 

734 

735 HF caches a file at ``models--<repo>/snapshots/<rev>/[<subdir>/]<name>``. A 

736 subdir-quant giant (unsloth stores quants under e.g. ``Q4_K_M/``) must 

737 register under that subdir-relative name so its manifest key round-trips with 

738 the ref; ``file_path.name`` alone would drop the subdir. Falls back to the 

739 basename when the path is not under a snapshot revision dir. 

740 """ 

741 parts = file_path.parts 

742 if _HF_SNAPSHOTS_DIR not in parts: 

743 return file_path.name 

744 rev_index = parts.index(_HF_SNAPSHOTS_DIR) + 1 

745 relative_parts = parts[rev_index + 1 :] 

746 return "/".join(relative_parts) if relative_parts else file_path.name 

747 

748 

749def _shard_accounting(first_shard_path: Path) -> tuple[int | None, list[str]]: 

750 """Total on-disk size and non-primary shard blob digests for a split GGUF. 

751 

752 ``(None, [])`` for a single-file model. For a split GGUF, the sibling shards 

753 live next to the first shard; ``_blob_digest`` yields each shard's blob digest 

754 (the HF cache blob name, or a content hash in copy/non-symlink mode), so the 

755 shards are summed and the digests of shards 2..N collected for removal-time 

756 garbage collection. 

757 """ 

758 shard_names = split_shard_filenames(first_shard_path.name) 

759 if len(shard_names) <= 1: 

760 return None, [] 

761 total = 0 

762 shard_blobs: list[str] = [] 

763 for index, name in enumerate(shard_names): 

764 shard_path = first_shard_path.with_name(name) 

765 if not shard_path.exists(): 

766 continue 

767 total += shard_path.stat().st_size 

768 if index > 0: # the primary blob is tracked separately as manifest.blob 

769 shard_blobs.append(_blob_digest(shard_path)) 

770 return total, shard_blobs 

771 

772 

773def register_downloaded_model(entry: CatalogModel, file_path: Path) -> None: 

774 """Write a registry manifest for a freshly downloaded GGUF. 

775 

776 A failed manifest write raises ``RuntimeError`` with a plain message. The 

777 model list reads manifests only, so a pull that reported success without one 

778 would leave a model that is installed but not listed. 

779 """ 

780 registry = ModelRegistry(cfg.models_dir) 

781 gguf_filename = _repo_relative_gguf_name(file_path) 

782 total_size, shard_blobs = _shard_accounting(file_path) 

783 manifest = ModelManifest( 

784 hf_repo=entry.hf_repo, 

785 gguf_filename=gguf_filename, 

786 size_bytes=file_path.stat().st_size, 

787 task=entry.task, 

788 downloaded_at=datetime.now(UTC).isoformat(), 

789 total_size_bytes=total_size, 

790 shard_blobs=shard_blobs, 

791 ) 

792 try: 

793 registry.install(entry.hf_repo, gguf_filename, file_path, manifest) 

794 log.info("Registered %s/%s in manifest", entry.hf_repo, gguf_filename) 

795 except OSError as exc: 

796 ref = format_native_gguf_ref(entry.hf_repo, gguf_filename) 

797 raise RuntimeError( 

798 f"{ref} downloaded, but lilbee cannot save its record in " 

799 f"{cfg.models_dir}: {exc}. Fix the folder permissions or free disk space, " 

800 "then pull again." 

801 ) from exc