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

355 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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.download import split_shard_filenames 

25from lilbee.catalog.query import reclassify_by_name 

26from lilbee.catalog.refs import ( 

27 NATIVE_GGUF_REF_MIN_SLASHES, 

28 format_native_gguf_ref, 

29 is_bare_hf_repo, 

30) 

31from lilbee.catalog.types import ModelTask 

32from lilbee.core.config.model import cfg 

33from lilbee.core.security import validate_path_within 

34 

35if TYPE_CHECKING: 

36 from lilbee.catalog.models import CatalogModel 

37 

38log = logging.getLogger(__name__) 

39 

40_HASH_CHUNK_SIZE = 8192 # bytes read per iteration when hashing 

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

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

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

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

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

46 

47REPO_DIR_SEPARATOR = "--" 

48 

49 

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

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

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

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

54 return hf_repo 

55 

56 

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

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

59 if ( 

60 not filename 

61 or not _FILENAME_RE.match(filename) 

62 or ".." in filename 

63 or filename.startswith("/") 

64 ): 

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

66 return filename 

67 

68 

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

70 

71 

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

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

74 

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

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

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

78 """ 

79 if not ref.endswith(".gguf") or ref.count("/") < NATIVE_GGUF_REF_MIN_SLASHES: 

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

81 parts = ref.split("/") 

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

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

84 return _validate_hf_repo(hf_repo), _validate_gguf_filename(gguf_filename) 

85 

86 

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

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

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

90 

91 

92@dataclass 

93class ModelManifest: 

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

95 

96 hf_repo: str 

97 gguf_filename: str 

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

99 task: ModelTask 

100 downloaded_at: str # ISO 8601 

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

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

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

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

105 total_size_bytes: int | None = None 

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

107 

108 @property 

109 def ref(self) -> str: 

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

111 

112 @property 

113 def disk_size_bytes(self) -> int: 

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

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

116 

117 

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

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

120 

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

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

123 """ 

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

125 tmp_path = Path(tmp_name) 

126 try: 

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

128 shutil.copyfileobj(src, dst) 

129 os.replace(tmp_path, blob_path) 

130 except BaseException: 

131 tmp_path.unlink(missing_ok=True) 

132 raise 

133 

134 

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

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

137 

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

139 interrupted download and must not count as installed. 

140 """ 

141 try: 

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

143 except OSError: 

144 return False 

145 

146 

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

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

149 h = hashlib.sha256() 

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

151 while True: 

152 chunk = f.read(_HASH_CHUNK_SIZE) 

153 if not chunk: 

154 break 

155 h.update(chunk) 

156 return h.hexdigest() 

157 

158 

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

160 

161 

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

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

164 

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

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

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

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

169 """ 

170 real = source_path.resolve() 

171 if real.parent.name == "blobs" and _SHA256_HEX.fullmatch(real.name): 

172 return real.name 

173 return _sha256_file(source_path) 

174 

175 

176class ModelRegistry: 

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

178 

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

180 self._root = models_dir 

181 self._manifests_dir = models_dir / "manifests" 

182 

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

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

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

186 

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

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

189 

190 A single-file GGUF resolves to its content-hashed blob; a split GGUF to 

191 its first shard's snapshot symlink (so llama.cpp finds the sibling shards). 

192 

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

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

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

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

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

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

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

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

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

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

203 """ 

204 if is_bare_hf_repo(ref): 

205 return self._resolve_repo_only(_validate_hf_repo(ref)) 

206 hf_repo, gguf_filename = parse_hf_ref(ref) 

207 shards = split_shard_filenames(gguf_filename) 

208 if len(shards) > 1: 

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

210 manifest = self._read_manifest(hf_repo, gguf_filename) 

211 if manifest is not None and manifest.blob is not None: 

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

213 if _blob_size_matches(blob_file, manifest.size_bytes): 

214 return blob_file 

215 recovered = self._find_cached_gguf(hf_repo, gguf_filename) 

216 if recovered is not None: 

217 self._reregister_from_cache(hf_repo, gguf_filename, recovered) 

218 return recovered 

219 if manifest is None: 

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

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

222 # specific diagnostic so a corrupted cache stays debuggable. 

223 cache_path = self._repo_cache_dir(manifest.hf_repo) 

224 if not cache_path.exists(): 

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

226 if manifest.blob is None: 

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

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

229 if blob_file.exists(): 

230 raise KeyError( 

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

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

233 ) 

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

235 

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

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

238 

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

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

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

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

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

244 re-pull then skipped. 

245 """ 

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

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

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

249 if first_shard is None: 

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

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

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

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

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

255 self._reregister_from_cache( 

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

257 ) 

258 return first_shard 

259 

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

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

262 

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

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

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

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

267 installed). 

268 """ 

269 manifest_dir = self._manifests_dir / repo_to_dir(hf_repo) 

270 if manifest_dir.is_dir(): 

271 # rglob, like list_installed: a quant-subdir ref writes its manifest one 

272 # directory deeper, so a non-recursive scan would miss it and fall through 

273 # to the slower huggingface_hub cache recovery. 

274 for mf in sorted(manifest_dir.rglob("*.gguf.json")): 

275 manifest = self._load_manifest_file(mf) 

276 if manifest is None or manifest.blob is None: 

277 continue 

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

279 if blob.exists(): 

280 return blob 

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

282 shards = split_shard_filenames(filename) 

283 if len(shards) > 1: 

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

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

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

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

288 if filename != shards[0]: 

289 continue 

290 with contextlib.suppress(KeyError): 

291 return self._resolve_split( 

292 format_native_gguf_ref(hf_repo, filename), hf_repo, shards 

293 ) 

294 continue 

295 recovered = self._find_cached_gguf(hf_repo, filename) 

296 if recovered is not None: 

297 self._reregister_from_cache(hf_repo, filename, recovered) 

298 return recovered 

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

300 

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

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

303 if not self._root.is_dir(): 

304 return set() 

305 from huggingface_hub import scan_cache_dir 

306 

307 info = scan_cache_dir(self._root) 

308 return { 

309 f.file_name 

310 for repo in info.repos 

311 if repo.repo_id == hf_repo 

312 for rev in repo.revisions 

313 for f in rev.files 

314 if f.file_name.endswith(".gguf") 

315 } 

316 

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

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

319 

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

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

322 """ 

323 from huggingface_hub import try_to_load_from_cache 

324 

325 hit = try_to_load_from_cache( 

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

327 ) 

328 candidate: Path | None = None 

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

330 candidate = Path(hit) 

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

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

333 if snapshots.is_dir(): 

334 basename = Path(gguf_filename).name 

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

336 # recently materialized one over an arbitrary lexicographic pick. 

337 candidate = max( 

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

339 ) 

340 if candidate is None: 

341 return None 

342 try: 

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

344 except ValueError: 

345 return None 

346 return candidate 

347 

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

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

350 

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

352 bounded to the cache directory. 

353 """ 

354 symlink = self._snapshot_gguf_path(hf_repo, gguf_filename) 

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

356 

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

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

359 

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

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

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

363 """ 

364 shards = split_shard_filenames(gguf_filename) 

365 if len(shards) == 1: 

366 return True 

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

368 

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

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

371 

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

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

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

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

376 """ 

377 first = self.resolve(ref) 

378 _repo, filename = parse_hf_ref(ref) 

379 candidates = ( 

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

381 ) 

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

383 

384 def _reregister_from_cache( 

385 self, 

386 hf_repo: str, 

387 gguf_filename: str, 

388 blob_path: Path, 

389 snapshot_path: Path | None = None, 

390 ) -> None: 

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

392 

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

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

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

396 """ 

397 ref = format_native_gguf_ref(hf_repo, gguf_filename) 

398 try: 

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

400 total_size, shard_blobs = ( 

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

402 ) 

403 self._write_manifest( 

404 ModelManifest( 

405 hf_repo=hf_repo, 

406 gguf_filename=gguf_filename, 

407 size_bytes=blob_path.stat().st_size, 

408 task=task, 

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

410 blob=blob_path.name, # the blob's filename is its sha in the HF cache 

411 total_size_bytes=total_size, 

412 shard_blobs=shard_blobs, 

413 ) 

414 ) 

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

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

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

418 

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

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

421 try: 

422 self.resolve(ref) 

423 return True 

424 except (KeyError, ValueError): 

425 return False 

426 

427 def install( 

428 self, 

429 hf_repo: str, 

430 gguf_filename: str, 

431 source_path: Path, 

432 manifest: ModelManifest, 

433 ) -> Path: 

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

435 digest = _blob_digest(source_path) 

436 cache_path = self._repo_cache_dir(hf_repo) 

437 blobs_dir = cache_path / "blobs" 

438 blob_path = blobs_dir / digest 

439 if not blob_path.exists(): 

440 blobs_dir.mkdir(parents=True, exist_ok=True) 

441 _copy_atomic(source_path, blob_path) 

442 

443 updated = ModelManifest( 

444 hf_repo=hf_repo, 

445 gguf_filename=gguf_filename, 

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

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

448 size_bytes=source_path.stat().st_size, 

449 task=manifest.task, 

450 downloaded_at=manifest.downloaded_at, 

451 blob=digest, 

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

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

454 total_size_bytes=manifest.total_size_bytes, 

455 shard_blobs=manifest.shard_blobs, 

456 ) 

457 self._write_manifest(updated) 

458 return blob_path 

459 

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

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

462 

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

464 when no other installed manifest references the same digest. 

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

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

467 deleted model leaves no orphan bytes behind. 

468 """ 

469 try: 

470 hf_repo, gguf_filename = parse_hf_ref(ref) 

471 except ValueError: 

472 return False 

473 manifest = self._read_manifest(hf_repo, gguf_filename) 

474 if manifest is None: 

475 return False 

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

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

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

479 manifest_path = self._manifest_path(hf_repo, gguf_filename) 

480 manifest_path.unlink() 

481 repo_dir = manifest_path.parent 

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

483 repo_dir.rmdir() 

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

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

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

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

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

489 for digest in [manifest.blob, *shard_blobs]: 

490 if digest is not None: 

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

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

493 return True 

494 

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

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

497 

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

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

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

501 """ 

502 with contextlib.suppress(Exception): 

503 shards = self.shard_paths(ref) 

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

505 return [] 

506 

507 def _gc_blob( 

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

509 ) -> None: 

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

511 and possibly the whole repo are unused. 

512 

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

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

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

516 only the specific blob file is removed when no remaining 

517 manifest still references its digest. 

518 

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

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

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

522 """ 

523 cache_path = self._repo_cache_dir(hf_repo) 

524 try: 

525 validate_path_within(cache_path, self._root) 

526 except ValueError: 

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

528 return 

529 if siblings is None: 

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

531 if not siblings: 

532 if cache_path.exists(): 

533 shutil.rmtree(cache_path) 

534 return 

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

536 return 

537 blob_file = cache_path / "blobs" / digest 

538 try: 

539 validate_path_within(blob_file, self._root) 

540 except ValueError: 

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

542 return 

543 if blob_file.exists(): 

544 blob_file.unlink() 

545 

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

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

548 

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

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

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

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

553 """ 

554 manifests: list[ModelManifest] = [] 

555 if not self._manifests_dir.exists(): 

556 return manifests 

557 for repo_dir in sorted(self._manifests_dir.iterdir()): 

558 if not repo_dir.is_dir(): 

559 continue 

560 # rglob, not glob: a quant-subdir ref (unsloth stores quants under e.g. 

561 # Q4_K_S/<model>.gguf) writes its manifest one level deeper, so a 

562 # non-recursive scan omitted it from `model list` and /v1/models, and 

563 # opencode silently fell back to its own provider. 

564 for tag_file in sorted(repo_dir.rglob("*.gguf.json")): 

565 manifest = self._load_manifest_file(tag_file) 

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

567 manifests.append(manifest) 

568 return manifests 

569 

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

571 """True iff *manifest* points at a blob whose on-disk size matches.""" 

572 if manifest.blob is None: 

573 return False 

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

575 return _blob_size_matches(blob_file, manifest.size_bytes) 

576 

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

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

579 try: 

580 hf_repo, gguf_filename = parse_hf_ref(ref) 

581 except ValueError: 

582 return None 

583 return self._read_manifest(hf_repo, gguf_filename) 

584 

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

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

587 

588 Alphabetical-first when several quants are installed, matching 

589 ``_resolve_repo_only``'s determinism. 

590 """ 

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

592 return refs[0] if refs else None 

593 

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

595 repo = _validate_hf_repo(hf_repo) 

596 filename = _validate_gguf_filename(gguf_filename) 

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

598 validate_path_within(path, self._manifests_dir) 

599 return path 

600 

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

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

603 

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

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

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

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

608 tmp_path: str | None = None 

609 try: 

610 with tempfile.NamedTemporaryFile( 

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

612 ) as tmp: 

613 tmp_path = tmp.name 

614 tmp.write(data) 

615 os.replace(tmp_path, path) 

616 except BaseException: 

617 if tmp_path is not None: 

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

619 raise 

620 

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

622 if not path.exists(): 

623 return None 

624 try: 

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

626 return ModelManifest(**data) 

627 # UnicodeDecodeError is a ValueError, not a JSONDecodeError. 

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

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

630 return None 

631 

632 

633_HF_SNAPSHOTS_DIR = "snapshots" 

634 

635 

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

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

638 

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

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

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

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

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

644 """ 

645 parts = file_path.parts 

646 if _HF_SNAPSHOTS_DIR not in parts: 

647 return file_path.name 

648 rev_index = parts.index(_HF_SNAPSHOTS_DIR) + 1 

649 relative_parts = parts[rev_index + 1 :] 

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

651 

652 

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

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

655 

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

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

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

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

660 garbage collection. 

661 """ 

662 shard_names = split_shard_filenames(first_shard_path.name) 

663 if len(shard_names) <= 1: 

664 return None, [] 

665 total = 0 

666 shard_blobs: list[str] = [] 

667 for index, name in enumerate(shard_names): 

668 shard_path = first_shard_path.with_name(name) 

669 if not shard_path.exists(): 

670 continue 

671 total += shard_path.stat().st_size 

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

673 shard_blobs.append(_blob_digest(shard_path)) 

674 return total, shard_blobs 

675 

676 

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

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

679 

680 A failed manifest write is logged, not raised, when the GGUF is still in the 

681 HF cache (``resolve`` recovers from it); if it isn't, the download itself is 

682 broken and the failure propagates so the caller reports it. 

683 """ 

684 registry = ModelRegistry(cfg.models_dir) 

685 gguf_filename = _repo_relative_gguf_name(file_path) 

686 total_size, shard_blobs = _shard_accounting(file_path) 

687 manifest = ModelManifest( 

688 hf_repo=entry.hf_repo, 

689 gguf_filename=gguf_filename, 

690 size_bytes=file_path.stat().st_size, 

691 task=entry.task, 

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

693 total_size_bytes=total_size, 

694 shard_blobs=shard_blobs, 

695 ) 

696 try: 

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

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

699 except Exception: 

700 ref = format_native_gguf_ref(entry.hf_repo, gguf_filename) 

701 if not registry.is_installed(ref): 

702 raise 

703 log.warning( 

704 "Manifest write failed for %s; recovered via the model cache", ref, exc_info=True 

705 )