Coverage for src/lilbee/cli/model.py: 100%
172 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""`lilbee model` sub-app: list/show/pull/rm/browse for installed models.
3Thin Typer wrapper around the surface-agnostic use-cases in
4:mod:`lilbee.app.models`. Bare result models live in ``app.models``;
5the Rich renderers below adapt them for human-readable terminal output.
6"""
8from __future__ import annotations
10import sys
11from pathlib import Path
12from typing import TYPE_CHECKING
14import typer
15from rich.console import Console
16from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn
17from rich.table import Table
19from lilbee.app.models import (
20 ListModelsResult,
21 PullEvent,
22 PullProgressEvent,
23 PullResult,
24 PullStatus,
25 ShowModelResult,
26 list_models_data,
27 pull_model_data,
28 remove_model_data,
29 show_model_data,
30)
31from lilbee.cli import theme
32from lilbee.cli.app import (
33 apply_overrides,
34 console,
35 data_dir_option,
36 global_option,
37)
38from lilbee.cli.helpers import json_output
39from lilbee.core.config import cfg
41if TYPE_CHECKING:
42 from collections.abc import Callable
44 from lilbee.catalog import DownloadProgress
45 from lilbee.catalog.types import ModelSource, ModelTask
48def _render_list(data: ListModelsResult) -> Table:
49 table = Table(title="Installed models")
50 table.add_column("Name", style=theme.ACCENT)
51 table.add_column("Source", style=theme.MUTED)
52 table.add_column("Task")
53 table.add_column("Size", justify="right")
54 for entry in data.models:
55 size = f"{entry.size_gb:.2f} GB" if entry.size_gb is not None else ""
56 table.add_row(entry.name, entry.source, entry.task or "", size)
57 return table
60def _render_show(data: ShowModelResult) -> str:
61 lines = [f"[{theme.ACCENT}]{data.model}[/{theme.ACCENT}]"]
62 if data.catalog is not None:
63 lines.extend(
64 [
65 f" display_name: {data.catalog.display_name}",
66 f" task: {data.catalog.task}",
67 f" size_gb: {data.catalog.size_gb}",
68 f" min_ram_gb: {data.catalog.min_ram_gb}",
69 f" hf_repo: {data.catalog.hf_repo}",
70 f" description: {data.catalog.description}",
71 ]
72 )
73 lines.append(f" installed: {data.installed}")
74 if data.source:
75 lines.append(f" source: {data.source}")
76 if data.path:
77 lines.append(f" path: {data.path}")
78 if data.manifest is not None:
79 lines.append(f" downloaded: {data.manifest.downloaded_at}")
80 return "\n".join(lines)
83model_app = typer.Typer(
84 name="model",
85 help="Manage installed and available models (pull / list / show / rm / browse).",
86 no_args_is_help=True,
87)
89_source_option = typer.Option(
90 None,
91 "--source",
92 "-s",
93 help="Filter by source: native, remote, ollama, lm_studio, or frontier (default: all).",
94)
95_task_option = typer.Option(
96 None,
97 "--task",
98 "-t",
99 help="Filter by task: 'chat', 'embedding', 'vision', or 'rerank'.",
100)
101_yes_option = typer.Option(
102 False,
103 "--yes",
104 "-y",
105 help="Skip confirmation prompt.",
106)
109def _parse_source_or_bad_param(value: str | None) -> ModelSource | None:
110 """Parse a CLI --source value, raising typer.BadParameter on bad input."""
111 from lilbee.catalog.types import ModelSource
113 try:
114 return ModelSource.parse(value)
115 except ValueError as exc:
116 if cfg.json_mode:
117 json_output({"error": str(exc)})
118 raise SystemExit(1) from None
119 raise typer.BadParameter(str(exc)) from exc
122def _parse_task_or_bad_param(value: str | None) -> ModelTask | None:
123 """Parse a CLI --task value, mirroring --source's JSON-envelope + friendly error.
125 Without this, an invalid --task leaked Python's "'x' is not a valid ModelTask"
126 and broke --json by emitting Typer usage text instead of the error envelope.
127 """
128 from lilbee.catalog.types import ModelTask
130 if not value:
131 return None
132 try:
133 return ModelTask(value)
134 except ValueError:
135 allowed = ", ".join(t.value for t in ModelTask)
136 msg = f"invalid task {value!r}; expected one of: {allowed}"
137 if cfg.json_mode:
138 json_output({"error": msg})
139 raise SystemExit(1) from None
140 raise typer.BadParameter(msg) from None
143def _apply_catalog_overrides(*, data_dir: Path | None, use_global: bool) -> None:
144 """Apply CLI overrides for a catalog command, with the fleet left cold.
146 Catalog commands list, read, download and delete model files; none of them
147 runs inference, so none should pay for a fleet warm (or its alarming
148 warm-up traceback on a slow host). Setting it here rather than at each
149 command means a catalog command added later cannot silently eager-start.
150 """
151 apply_overrides(data_dir=data_dir, use_global=use_global)
152 cfg.worker_pool_eager_start = False
155@model_app.command("list")
156def list_cmd(
157 source: str | None = _source_option,
158 task: str | None = _task_option,
159 data_dir: Path | None = data_dir_option,
160 use_global: bool = global_option,
161) -> None:
162 """List installed models across all sources."""
163 _apply_catalog_overrides(data_dir=data_dir, use_global=use_global)
164 parsed_task = _parse_task_or_bad_param(task)
165 data = list_models_data(source=_parse_source_or_bad_param(source), task=parsed_task)
166 if cfg.json_mode:
167 json_output(data.model_dump())
168 return
169 if not data.models:
170 console.print("No models installed.")
171 return
172 console.print(_render_list(data))
175@model_app.command("show")
176def show_cmd(
177 ref: str = typer.Argument(..., help="Model ref (e.g. 'Qwen/Qwen3-0.6B-GGUF')."),
178 data_dir: Path | None = data_dir_option,
179 use_global: bool = global_option,
180) -> None:
181 """Show catalog and installed metadata for a model."""
182 from lilbee.modelhub.model_manager import ModelNotFoundError
184 _apply_catalog_overrides(data_dir=data_dir, use_global=use_global)
185 try:
186 data = show_model_data(ref)
187 except ModelNotFoundError as exc:
188 if cfg.json_mode:
189 json_output({"error": str(exc)})
190 else:
191 console.print(f"[{theme.ERROR}]{exc}[/{theme.ERROR}]")
192 raise typer.Exit(1) from None
193 if cfg.json_mode:
194 json_output(data.model_dump())
195 return
196 console.print(_render_show(data))
199def _run_pull(
200 ref: str,
201 src: ModelSource,
202 on_update: Callable[[DownloadProgress], None],
203 *,
204 allow_unsupported: bool = False,
205) -> PullResult:
206 """Invoke ``pull_model_data`` and translate known errors to typer.Exit."""
207 from lilbee.catalog.compat import UnsupportedArchError
209 try:
210 return pull_model_data(ref, src, on_update=on_update, allow_unsupported=allow_unsupported)
211 except UnsupportedArchError as exc:
212 msg = (
213 f"Architecture {exc.architecture!r} is not supported by this lilbee build.\n"
214 "Pass --allow-unsupported to try anyway."
215 )
216 if cfg.json_mode:
217 json_output(
218 {
219 "error": "unsupported_arch",
220 "arch": exc.architecture,
221 "ref": exc.ref,
222 }
223 )
224 else:
225 console.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] {msg}")
226 raise typer.Exit(1) from None
227 except (RuntimeError, PermissionError) as exc:
228 if cfg.json_mode:
229 json_output({"error": str(exc)})
230 else:
231 console.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] {exc}")
232 raise typer.Exit(1) from None
235def _pull_json_stream(ref: str, src: ModelSource, *, allow_unsupported: bool) -> None:
236 """Emit newline-delimited JSON progress events, then the final result."""
238 def on_update(p: DownloadProgress) -> None:
239 event = PullProgressEvent(
240 model=ref, percent=p.percent, detail=p.detail, cache_hit=p.is_cache_hit
241 )
242 json_output(event.model_dump())
244 final = _run_pull(ref, src, on_update, allow_unsupported=allow_unsupported)
245 json_output({**final.model_dump(), "event": PullEvent.DONE.value})
248def _pull_interactive_progress(ref: str, src: ModelSource, *, allow_unsupported: bool) -> None:
249 """Drive Rich's Live progress bar during a native HuggingFace download."""
250 err_console = Console(stderr=True, force_terminal=True)
251 with Progress(
252 TextColumn("[progress.description]{task.description}"),
253 BarColumn(),
254 TextColumn("{task.percentage:>3.0f}%"),
255 TextColumn("{task.fields[detail]}"),
256 TimeRemainingColumn(),
257 console=err_console,
258 transient=False,
259 ) as progress:
260 task_id = progress.add_task(f"Downloading {ref}", total=100, detail="")
262 def on_update(p: DownloadProgress) -> None:
263 progress.update(task_id, completed=p.percent, detail=p.detail)
265 final = _run_pull(ref, src, on_update, allow_unsupported=allow_unsupported)
267 if final.status == PullStatus.ALREADY_INSTALLED:
268 console.print(f"{ref} is already installed.")
269 else:
270 console.print(f"Pulled [{theme.ACCENT}]{ref}[/{theme.ACCENT}].")
273@model_app.command("pull")
274def pull_cmd(
275 ref: str = typer.Argument(..., help="Model ref to download (e.g. 'Qwen/Qwen3-0.6B-GGUF')."),
276 source: str = typer.Option(
277 "native",
278 "--source",
279 "-s",
280 help="Pull from 'native' (HuggingFace GGUF) or 'remote' (SDK-managed).",
281 ),
282 allow_unsupported: bool = typer.Option(
283 False,
284 "--allow-unsupported",
285 help="Pull even if the architecture isn't in the supported set (load may still fail).",
286 ),
287 data_dir: Path | None = data_dir_option,
288 use_global: bool = global_option,
289) -> None:
290 """Download a model."""
291 from lilbee.catalog.types import ModelSource
293 _apply_catalog_overrides(data_dir=data_dir, use_global=use_global)
294 src = _parse_source_or_bad_param(source) or ModelSource.NATIVE
295 if cfg.json_mode:
296 _pull_json_stream(ref, src, allow_unsupported=allow_unsupported)
297 else:
298 _pull_interactive_progress(ref, src, allow_unsupported=allow_unsupported)
301def _confirm_remove_or_exit(ref: str, yes: bool) -> None:
302 if yes or cfg.json_mode:
303 return
304 if not typer.confirm(f"Remove {ref}?", default=False):
305 console.print("Aborted.")
306 raise typer.Exit(0)
309@model_app.command("rm")
310def rm_cmd(
311 ref: str = typer.Argument(..., help="Model ref to remove."),
312 source: str | None = _source_option,
313 yes: bool = _yes_option,
314 data_dir: Path | None = data_dir_option,
315 use_global: bool = global_option,
316) -> None:
317 """Remove an installed model."""
318 _apply_catalog_overrides(data_dir=data_dir, use_global=use_global)
319 src = _parse_source_or_bad_param(source)
320 _confirm_remove_or_exit(ref, yes)
321 try:
322 data = remove_model_data(ref, source=src)
323 except ValueError as exc:
324 if cfg.json_mode:
325 json_output({"error": str(exc)})
326 else:
327 console.print(f"[{theme.ERROR}]{exc}[/{theme.ERROR}]")
328 raise typer.Exit(1) from None
329 if cfg.json_mode:
330 json_output(data.model_dump())
331 if not data.deleted:
332 raise typer.Exit(1)
333 return
334 if not data.deleted:
335 console.print(f"[{theme.WARNING}]Not found: {ref}[/{theme.WARNING}]")
336 raise typer.Exit(1)
337 suffix = f" ({data.freed_gb:.2f} GB freed)" if data.freed_gb else ""
338 console.print(f"Removed [{theme.ACCENT}]{ref}[/{theme.ACCENT}]{suffix}.")
341def _is_interactive_terminal() -> bool:
342 """Return True when both stdin and stdout are connected to a TTY.
344 Extracted as a module-level helper so tests can patch it deterministically;
345 CliRunner replaces ``sys.stdin`` during invoke which makes direct
346 monkey-patching of ``sys.stdin.isatty`` unreliable.
347 """
348 return sys.stdin.isatty() and sys.stdout.isatty()
351@model_app.command("browse")
352def browse_cmd(
353 data_dir: Path | None = data_dir_option,
354 use_global: bool = global_option,
355) -> None:
356 """Open the Textual TUI directly on the model catalog screen.
358 Exit codes follow the project convention: 2 for invalid flag
359 combinations (``--json`` with an interactive-only command), 1 for
360 runtime environment failures (no TTY).
361 """
362 _apply_catalog_overrides(data_dir=data_dir, use_global=use_global)
363 if cfg.json_mode:
364 json_output({"error": "model browse is interactive, not available in --json mode"})
365 raise typer.Exit(2)
366 if not _is_interactive_terminal():
367 console.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] model browse requires a terminal.")
368 raise typer.Exit(1)
370 from lilbee.cli.tui import run_tui
371 from lilbee.cli.tui.messages import CATALOG_VIEW
373 run_tui(initial_view=CATALOG_VIEW)