Coverage for src / lilbee / cli / model.py: 100%

298 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-29 19:16 +0000

1"""`lilbee model` sub-app: list/show/pull/rm/browse for installed models. 

2 

3Thin CLI and shared data helpers over 

4:class:`lilbee.model_manager.ModelManager`. The ``*_data`` functions 

5return Pydantic models so MCP tools and CLI commands share a single, 

6typed implementation. 

7 

8Heavy imports (:mod:`lilbee.catalog`, :mod:`lilbee.model_manager`, 

9:mod:`lilbee.registry`, :mod:`lilbee.cli.tui`) are deferred to function 

10bodies so importing this module at CLI startup stays cheap. 

11""" 

12 

13from __future__ import annotations 

14 

15from enum import StrEnum 

16from pathlib import Path 

17from typing import TYPE_CHECKING, Any 

18 

19import typer 

20from pydantic import BaseModel, Field 

21from rich.console import Console 

22from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn 

23from rich.table import Table 

24 

25from lilbee.cli import theme 

26from lilbee.cli.app import ( 

27 apply_overrides, 

28 console, 

29 data_dir_option, 

30 global_option, 

31) 

32from lilbee.cli.helpers import json_output 

33from lilbee.config import cfg 

34 

35if TYPE_CHECKING: 

36 from collections.abc import Callable 

37 

38 from lilbee.catalog import CatalogModel, DownloadProgress 

39 from lilbee.model_manager import ModelSource, RemoteModel 

40 from lilbee.registry import ModelManifest 

41 

42 

43_BYTES_PER_GB = 1024**3 # Model sizes are reported to users in GiB. 

44_BACKEND_LIST_TIMEOUT_S = 2.0 # Keep `model list` snappy when backend is down. 

45 

46 

47def _bytes_to_gb(n: int) -> float: 

48 """Convert bytes to GiB rounded to 2 decimals for user display.""" 

49 return round(n / _BYTES_PER_GB, 2) 

50 

51 

52class ModelCommand(StrEnum): 

53 """Command field values for model sub-app JSON output.""" 

54 

55 LIST = "model list" 

56 SHOW = "model show" 

57 PULL = "model pull" 

58 RM = "model rm" 

59 

60 

61class PullStatus(StrEnum): 

62 OK = "ok" 

63 ALREADY_INSTALLED = "already_installed" 

64 

65 

66class PullEvent(StrEnum): 

67 PROGRESS = "progress" 

68 DONE = "done" 

69 

70 

71class ModelEntry(BaseModel): 

72 """One row of `lilbee model list` output.""" 

73 

74 name: str 

75 source: str 

76 task: str | None = None 

77 size_gb: float | None = None 

78 display_name: str = "" 

79 

80 @classmethod 

81 def from_native(cls, ref: str, manifest: ModelManifest | None) -> ModelEntry: 

82 from lilbee.catalog import clean_display_name 

83 from lilbee.model_manager import ModelSource 

84 

85 return cls( 

86 name=ref, 

87 source=ModelSource.NATIVE.value, 

88 task=manifest.task if manifest else None, 

89 size_gb=_bytes_to_gb(manifest.size_bytes) if manifest else None, 

90 display_name=clean_display_name(manifest.hf_repo) if manifest else "", 

91 ) 

92 

93 @classmethod 

94 def from_backend(cls, ref: str, remote: RemoteModel | None) -> ModelEntry: 

95 from lilbee.model_manager import ModelSource 

96 

97 return cls( 

98 name=ref, 

99 source=ModelSource.REMOTE.value, 

100 task=remote.task if remote else None, 

101 size_gb=None, 

102 display_name=remote.parameter_size if remote else "", 

103 ) 

104 

105 

106class ListModelsResult(BaseModel): 

107 command: str = ModelCommand.LIST 

108 models: list[ModelEntry] 

109 total: int 

110 

111 def __rich__(self) -> Table: 

112 table = Table(title="Installed models") 

113 table.add_column("Name", style=theme.ACCENT) 

114 table.add_column("Source", style=theme.MUTED) 

115 table.add_column("Task") 

116 table.add_column("Size", justify="right") 

117 for entry in self.models: 

118 size = f"{entry.size_gb:.2f} GB" if entry.size_gb is not None else "" 

119 table.add_row(entry.name, entry.source, entry.task or "", size) 

120 return table 

121 

122 

123class CatalogEntryData(BaseModel): 

124 ref: str 

125 display_name: str 

126 hf_repo: str 

127 gguf_filename: str 

128 size_gb: float 

129 min_ram_gb: float 

130 description: str 

131 task: str 

132 featured: bool 

133 recommended: bool 

134 

135 @classmethod 

136 def from_catalog_model(cls, entry: CatalogModel) -> CatalogEntryData: 

137 return cls( 

138 ref=entry.ref, 

139 display_name=entry.display_name, 

140 hf_repo=entry.hf_repo, 

141 gguf_filename=entry.gguf_filename, 

142 size_gb=entry.size_gb, 

143 min_ram_gb=entry.min_ram_gb, 

144 description=entry.description, 

145 task=entry.task, 

146 featured=entry.featured, 

147 recommended=entry.recommended, 

148 ) 

149 

150 

151class ManifestData(BaseModel): 

152 ref: str 

153 display_name: str 

154 task: str 

155 size_gb: float 

156 size_bytes: int 

157 hf_repo: str 

158 gguf_filename: str 

159 downloaded_at: str 

160 

161 @classmethod 

162 def from_manifest(cls, manifest: ModelManifest) -> ManifestData: 

163 from lilbee.catalog import clean_display_name 

164 

165 return cls( 

166 ref=manifest.ref, 

167 display_name=clean_display_name(manifest.hf_repo), 

168 task=manifest.task, 

169 size_gb=_bytes_to_gb(manifest.size_bytes), 

170 size_bytes=manifest.size_bytes, 

171 hf_repo=manifest.hf_repo, 

172 gguf_filename=manifest.gguf_filename, 

173 downloaded_at=manifest.downloaded_at, 

174 ) 

175 

176 

177class ShowModelResult(BaseModel): 

178 command: str = ModelCommand.SHOW 

179 model: str 

180 catalog: CatalogEntryData | None = None 

181 installed: bool = False 

182 source: str | None = None 

183 path: str | None = None 

184 manifest: ManifestData | None = None 

185 

186 def __rich__(self) -> str: 

187 lines = [f"[{theme.ACCENT}]{self.model}[/{theme.ACCENT}]"] 

188 if self.catalog is not None: 

189 lines.extend( 

190 [ 

191 f" display_name: {self.catalog.display_name}", 

192 f" task: {self.catalog.task}", 

193 f" size_gb: {self.catalog.size_gb}", 

194 f" min_ram_gb: {self.catalog.min_ram_gb}", 

195 f" hf_repo: {self.catalog.hf_repo}", 

196 f" description: {self.catalog.description}", 

197 ] 

198 ) 

199 lines.append(f" installed: {self.installed}") 

200 if self.source: 

201 lines.append(f" source: {self.source}") 

202 if self.path: 

203 lines.append(f" path: {self.path}") 

204 if self.manifest is not None: 

205 lines.append(f" downloaded: {self.manifest.downloaded_at}") 

206 return "\n".join(lines) 

207 

208 

209class PullResult(BaseModel): 

210 command: str = ModelCommand.PULL 

211 model: str 

212 source: str 

213 status: str 

214 path: str | None = None 

215 

216 

217class PullProgressEvent(BaseModel): 

218 command: str = ModelCommand.PULL 

219 event: str = PullEvent.PROGRESS 

220 model: str 

221 percent: float 

222 detail: str 

223 cache_hit: bool 

224 

225 

226class RemoveResult(BaseModel): 

227 command: str = ModelCommand.RM 

228 model: str 

229 deleted: bool 

230 freed_gb: float = Field(default=0.0) 

231 

232 

233def _native_manifest_index() -> dict[str, ModelManifest]: 

234 """Map ref string ('hf_repo/filename') to manifest for every installed native model.""" 

235 from lilbee.registry import ModelRegistry 

236 

237 registry = ModelRegistry(cfg.models_dir) 

238 return {m.ref: m for m in registry.list_installed()} 

239 

240 

241def _resolve_native_path(ref: str) -> str | None: 

242 """Return the on-disk path of an installed native model, if resolvable. 

243 

244 Swallows ``KeyError`` (manifest present but blob missing) and 

245 ``ValueError`` (malformed ref) so callers can treat the path as 

246 optional metadata. 

247 """ 

248 from lilbee.registry import ModelRegistry 

249 

250 try: 

251 return str(ModelRegistry(cfg.models_dir).resolve(ref)) 

252 except (KeyError, ValueError): 

253 return None 

254 

255 

256def _collect_native_entries() -> list[ModelEntry]: 

257 from lilbee.model_manager import ModelSource, get_model_manager 

258 

259 manifests = _native_manifest_index() 

260 refs = get_model_manager().list_installed(source=ModelSource.NATIVE) 

261 return [ModelEntry.from_native(ref, manifests.get(ref)) for ref in refs] 

262 

263 

264def _collect_backend_entries() -> list[ModelEntry]: 

265 from lilbee.model_manager import classify_remote_models 

266 

267 remote_list = classify_remote_models(cfg.remote_base_url, timeout=_BACKEND_LIST_TIMEOUT_S) 

268 remote_by_name = {rm.name: rm for rm in remote_list} 

269 return [ModelEntry.from_backend(ref, remote_by_name[ref]) for ref in sorted(remote_by_name)] 

270 

271 

272def list_models_data( 

273 source: ModelSource | None = None, 

274 task: str | None = None, 

275) -> ListModelsResult: 

276 """Build the list of installed models with source and task metadata. 

277 

278 Discovers remote models via a single HTTP call with a short timeout 

279 so the command stays responsive when the backend is down. 

280 """ 

281 from lilbee.model_manager import ModelSource 

282 

283 entries: list[ModelEntry] = [] 

284 if source is None or source is ModelSource.NATIVE: 

285 entries.extend(_collect_native_entries()) 

286 if source is None or source is ModelSource.REMOTE: 

287 entries.extend(_collect_backend_entries()) 

288 if task: 

289 entries = [e for e in entries if e.task == task] 

290 return ListModelsResult(models=entries, total=len(entries)) 

291 

292 

293def show_model_data(ref: str) -> ShowModelResult: 

294 """Return catalog and install metadata for *ref*. 

295 

296 Raises :class:`~lilbee.model_manager.ModelNotFoundError` if the ref 

297 is unknown to both the catalog and the installed set. 

298 """ 

299 from lilbee.catalog import find_catalog_entry 

300 from lilbee.model_manager import ModelNotFoundError, get_model_manager 

301 

302 entry = find_catalog_entry(ref) 

303 source = get_model_manager().get_source(ref) 

304 if entry is None and source is None: 

305 raise ModelNotFoundError(f"model not found: {ref}") 

306 manifest = _native_manifest_index().get(ref) 

307 return ShowModelResult( 

308 model=ref, 

309 catalog=CatalogEntryData.from_catalog_model(entry) if entry else None, 

310 installed=source is not None, 

311 source=source.value if source else None, 

312 manifest=ManifestData.from_manifest(manifest) if manifest else None, 

313 path=_resolve_native_path(ref) if manifest is not None else None, 

314 ) 

315 

316 

317def _backend_event_to_progress( 

318 on_update: Callable[[DownloadProgress], None], 

319 event: dict[str, Any], 

320) -> None: 

321 """Adapt an Ollama-style dict event into a DownloadProgress call.""" 

322 from lilbee.catalog import DownloadProgress 

323 

324 total = event.get("total", 0) or 0 

325 completed = event.get("completed", 0) or 0 

326 detail = event.get("status", "") or "" 

327 pct = int(completed * 100 / total) if total > 0 else 0 

328 on_update(DownloadProgress(percent=pct, detail=detail, is_cache_hit=False)) 

329 

330 

331def _build_pull_callbacks( 

332 on_update: Callable[[DownloadProgress], None] | None, 

333) -> tuple[Callable[[dict[str, Any]], None] | None, Callable[[int, int], None] | None]: 

334 """Build the (dict_cb, bytes_cb) pair for ModelManager.pull from on_update.""" 

335 import functools 

336 

337 from lilbee.catalog import make_download_callback 

338 

339 if on_update is None: 

340 return None, None 

341 dict_cb = functools.partial(_backend_event_to_progress, on_update) 

342 bytes_cb = make_download_callback(on_update) 

343 return dict_cb, bytes_cb 

344 

345 

346def pull_model_data( 

347 ref: str, 

348 source: ModelSource, 

349 *, 

350 on_update: Callable[[DownloadProgress], None] | None = None, 

351) -> PullResult: 

352 """Pull *ref* from *source* and return a typed result. 

353 

354 Progress updates are throttled by 

355 :func:`~lilbee.catalog.make_download_callback`, so callers see at 

356 most roughly 10 Hz of progress events. 

357 """ 

358 from lilbee.model_manager import get_model_manager 

359 

360 manager = get_model_manager() 

361 

362 if manager.is_installed(ref, source): 

363 return PullResult(model=ref, source=source.value, status=PullStatus.ALREADY_INSTALLED) 

364 

365 dict_cb, bytes_cb = _build_pull_callbacks(on_update) 

366 path = manager.pull(ref, source, on_progress=dict_cb, on_bytes=bytes_cb) 

367 return PullResult( 

368 model=ref, 

369 source=source.value, 

370 status=PullStatus.OK, 

371 path=str(path) if path is not None else None, 

372 ) 

373 

374 

375def remove_model_data( 

376 ref: str, 

377 source: ModelSource | None = None, 

378) -> RemoveResult: 

379 """Remove *ref* and return a typed result with freed size.""" 

380 from lilbee.model_manager import get_model_manager 

381 

382 manager = get_model_manager() 

383 manifests = _native_manifest_index() 

384 size_bytes = manifests[ref].size_bytes if ref in manifests else 0 

385 removed = manager.remove(ref, source=source) 

386 return RemoveResult( 

387 model=ref, 

388 deleted=removed, 

389 freed_gb=_bytes_to_gb(size_bytes), 

390 ) 

391 

392 

393model_app = typer.Typer( 

394 name="model", 

395 help="Manage installed and available models (pull / list / show / rm / browse).", 

396 no_args_is_help=True, 

397) 

398 

399_source_option = typer.Option( 

400 None, 

401 "--source", 

402 "-s", 

403 help="Filter by source: 'native' or 'remote' (default: all).", 

404) 

405_task_option = typer.Option( 

406 None, 

407 "--task", 

408 "-t", 

409 help="Filter by task: 'chat', 'embedding', 'vision', or 'rerank'.", 

410) 

411_yes_option = typer.Option( 

412 False, 

413 "--yes", 

414 "-y", 

415 help="Skip confirmation prompt.", 

416) 

417 

418 

419def _parse_source_or_bad_param(value: str | None) -> ModelSource | None: 

420 """Parse a CLI --source value, raising typer.BadParameter on bad input.""" 

421 from lilbee.model_manager import ModelSource 

422 

423 try: 

424 return ModelSource.parse(value) 

425 except ValueError as exc: 

426 if cfg.json_mode: 

427 json_output({"error": str(exc)}) 

428 raise SystemExit(1) from None 

429 raise typer.BadParameter(str(exc)) from exc 

430 

431 

432@model_app.command("list") 

433def list_cmd( 

434 source: str | None = _source_option, 

435 task: str | None = _task_option, 

436 data_dir: Path | None = data_dir_option, 

437 use_global: bool = global_option, 

438) -> None: 

439 """List installed models across all sources.""" 

440 apply_overrides(data_dir=data_dir, use_global=use_global) 

441 data = list_models_data(source=_parse_source_or_bad_param(source), task=task) 

442 if cfg.json_mode: 

443 json_output(data.model_dump()) 

444 return 

445 if not data.models: 

446 console.print("No models installed.") 

447 return 

448 console.print(data) 

449 

450 

451@model_app.command("show") 

452def show_cmd( 

453 ref: str = typer.Argument(..., help="Model ref (e.g. 'Qwen/Qwen3-0.6B-GGUF')."), 

454 data_dir: Path | None = data_dir_option, 

455 use_global: bool = global_option, 

456) -> None: 

457 """Show catalog and installed metadata for a model.""" 

458 from lilbee.model_manager import ModelNotFoundError 

459 

460 apply_overrides(data_dir=data_dir, use_global=use_global) 

461 try: 

462 data = show_model_data(ref) 

463 except ModelNotFoundError as exc: 

464 if cfg.json_mode: 

465 json_output({"error": str(exc)}) 

466 else: 

467 console.print(f"[{theme.ERROR}]{exc}[/{theme.ERROR}]") 

468 raise typer.Exit(1) from None 

469 if cfg.json_mode: 

470 json_output(data.model_dump()) 

471 return 

472 console.print(data) 

473 

474 

475def _run_pull( 

476 ref: str, 

477 src: ModelSource, 

478 on_update: Callable[[DownloadProgress], None], 

479) -> PullResult: 

480 """Invoke ``pull_model_data`` and translate known errors to typer.Exit.""" 

481 try: 

482 return pull_model_data(ref, src, on_update=on_update) 

483 except (RuntimeError, PermissionError) as exc: 

484 if cfg.json_mode: 

485 json_output({"error": str(exc)}) 

486 else: 

487 console.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] {exc}") 

488 raise typer.Exit(1) from None 

489 

490 

491def _pull_json_stream(ref: str, src: ModelSource) -> None: 

492 """Emit newline-delimited JSON progress events, then the final result.""" 

493 

494 def on_update(p: DownloadProgress) -> None: 

495 event = PullProgressEvent( 

496 model=ref, percent=p.percent, detail=p.detail, cache_hit=p.is_cache_hit 

497 ) 

498 json_output(event.model_dump()) 

499 

500 final = _run_pull(ref, src, on_update) 

501 json_output({**final.model_dump(), "event": PullEvent.DONE.value}) 

502 

503 

504def _pull_rich_progress(ref: str, src: ModelSource) -> None: 

505 """Drive a Rich progress bar during a native HuggingFace download.""" 

506 err_console = Console(stderr=True) 

507 with Progress( 

508 TextColumn("[progress.description]{task.description}"), 

509 BarColumn(), 

510 TextColumn("{task.percentage:>3.0f}%"), 

511 TextColumn("{task.fields[detail]}"), 

512 TimeRemainingColumn(), 

513 console=err_console, 

514 transient=False, 

515 ) as progress: 

516 task_id = progress.add_task(f"Downloading {ref}", total=100, detail="") 

517 

518 def on_update(p: DownloadProgress) -> None: 

519 progress.update(task_id, completed=p.percent, detail=p.detail) 

520 

521 final = _run_pull(ref, src, on_update) 

522 

523 if final.status == PullStatus.ALREADY_INSTALLED: 

524 console.print(f"{ref} is already installed.") 

525 else: 

526 console.print(f"Pulled [{theme.ACCENT}]{ref}[/{theme.ACCENT}].") 

527 

528 

529@model_app.command("pull") 

530def pull_cmd( 

531 ref: str = typer.Argument(..., help="Model ref to download (e.g. 'Qwen/Qwen3-0.6B-GGUF')."), 

532 source: str = typer.Option( 

533 "native", 

534 "--source", 

535 "-s", 

536 help="Pull from 'native' (HuggingFace GGUF) or 'remote' (SDK-managed).", 

537 ), 

538 data_dir: Path | None = data_dir_option, 

539 use_global: bool = global_option, 

540) -> None: 

541 """Download a model.""" 

542 from lilbee.model_manager import ModelSource 

543 

544 apply_overrides(data_dir=data_dir, use_global=use_global) 

545 src = _parse_source_or_bad_param(source) or ModelSource.NATIVE 

546 if cfg.json_mode: 

547 _pull_json_stream(ref, src) 

548 else: 

549 _pull_rich_progress(ref, src) 

550 

551 

552def _confirm_remove_or_exit(ref: str, yes: bool) -> None: 

553 if yes or cfg.json_mode: 

554 return 

555 if not typer.confirm(f"Remove {ref}?", default=False): 

556 console.print("Aborted.") 

557 raise typer.Exit(0) 

558 

559 

560@model_app.command("rm") 

561def rm_cmd( 

562 ref: str = typer.Argument(..., help="Model ref to remove."), 

563 source: str | None = _source_option, 

564 yes: bool = _yes_option, 

565 data_dir: Path | None = data_dir_option, 

566 use_global: bool = global_option, 

567) -> None: 

568 """Remove an installed model.""" 

569 apply_overrides(data_dir=data_dir, use_global=use_global) 

570 src = _parse_source_or_bad_param(source) 

571 _confirm_remove_or_exit(ref, yes) 

572 data = remove_model_data(ref, source=src) 

573 if cfg.json_mode: 

574 json_output(data.model_dump()) 

575 if not data.deleted: 

576 raise typer.Exit(1) 

577 return 

578 if not data.deleted: 

579 console.print(f"[{theme.WARNING}]Not found: {ref}[/{theme.WARNING}]") 

580 raise typer.Exit(1) 

581 suffix = f" ({data.freed_gb:.2f} GB freed)" if data.freed_gb else "" 

582 console.print(f"Removed [{theme.ACCENT}]{ref}[/{theme.ACCENT}]{suffix}.") 

583 

584 

585def _is_interactive_terminal() -> bool: 

586 """Return True when both stdin and stdout are connected to a TTY. 

587 

588 Extracted as a module-level helper so tests can patch it deterministically; 

589 CliRunner replaces ``sys.stdin`` during invoke which makes direct 

590 monkey-patching of ``sys.stdin.isatty`` unreliable. 

591 """ 

592 import sys 

593 

594 return sys.stdin.isatty() and sys.stdout.isatty() 

595 

596 

597@model_app.command("browse") 

598def browse_cmd( 

599 data_dir: Path | None = data_dir_option, 

600 use_global: bool = global_option, 

601) -> None: 

602 """Open the Textual TUI directly on the model catalog screen. 

603 

604 Exit codes follow the project convention: 2 for invalid flag 

605 combinations (``--json`` with an interactive-only command), 1 for 

606 runtime environment failures (no TTY). 

607 """ 

608 apply_overrides(data_dir=data_dir, use_global=use_global) 

609 if cfg.json_mode: 

610 json_output({"error": "model browse is interactive, not available in --json mode"}) 

611 raise typer.Exit(2) 

612 if not _is_interactive_terminal(): 

613 console.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] model browse requires a terminal.") 

614 raise typer.Exit(1) 

615 

616 # Browsing the catalog does not depend on documents, so skip auto-sync. 

617 from lilbee.cli.tui import run_tui 

618 

619 run_tui(auto_sync=False, initial_view="Catalog")