Coverage for src/lilbee/cli/launchers/warm_render.py: 100%
59 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"""Render chat-model warm progress from the server's SSE stream.
3A launcher consumes ``/api/warm/stream`` and drives a rich progress display so
4the user sees a real read-phase byte bar, then an engine-load spinner, while a
5large chat model loads, instead of a frozen line. Designed to degrade cleanly:
6when the stream can't be opened (server still binding, a transient transport
7error, or the endpoint absent) the caller falls back to a plain readiness poll.
8"""
10from __future__ import annotations
12import json
13from collections.abc import Iterator
15import httpx
16from rich.progress import (
17 BarColumn,
18 DownloadColumn,
19 Progress,
20 SpinnerColumn,
21 TaskID,
22 TextColumn,
23 TimeElapsedColumn,
24)
26from lilbee.catalog.formatting import display_label_for_ref
27from lilbee.cli.app import console
28from lilbee.providers.warm_progress import WarmPhase, WarmProgress
30_WARM_STREAM_PATH = "/api/warm/stream"
31_SSE_DATA_PREFIX = "data:"
32_STREAM_CONNECT_TIMEOUT_S = 5.0
33_DEFAULT_MODEL_LABEL = "chat model"
36def _model_label(ref: str | None) -> str:
37 """The canonical UI label for the warming model, or a generic fallback."""
38 return display_label_for_ref(ref) if ref else _DEFAULT_MODEL_LABEL
41def _iter_warm_events(base_url: str, timeout_s: float) -> Iterator[WarmProgress]:
42 """Yield ``WarmProgress`` snapshots parsed from the SSE warm stream.
44 Raises ``httpx.HTTPError`` if the stream cannot be opened (e.g. a server
45 without the endpoint), so the caller can fall back to a plain poll.
46 """
47 timeout = httpx.Timeout(timeout_s, connect=_STREAM_CONNECT_TIMEOUT_S)
48 with httpx.stream("GET", f"{base_url}{_WARM_STREAM_PATH}", timeout=timeout) as resp:
49 resp.raise_for_status()
50 for line in resp.iter_lines():
51 if not line.startswith(_SSE_DATA_PREFIX):
52 continue
53 payload = line[len(_SSE_DATA_PREFIX) :].strip()
54 if not payload:
55 continue
56 try:
57 data = json.loads(payload)
58 except json.JSONDecodeError:
59 continue
60 # The terminal ``done`` event carries ``{}`` (no phase); only real
61 # snapshots are yielded, so the renderer ignores it naturally.
62 if isinstance(data, dict) and "phase" in data:
63 yield WarmProgress.model_validate(data)
66def _apply(progress: Progress, task_id: TaskID, snap: WarmProgress) -> None:
67 """Reflect one warm snapshot onto the rich progress task."""
68 if snap.phase is WarmPhase.READING_WEIGHTS:
69 progress.update(
70 task_id,
71 description=f"Reading {_model_label(snap.model_ref)} weights",
72 total=snap.bytes_total or None,
73 completed=snap.bytes_done,
74 detail=snap.detail or "",
75 )
76 elif snap.phase is WarmPhase.LOADING_ENGINE:
77 # No byte signal during the VRAM load: drop to an indeterminate spinner.
78 progress.update(
79 task_id,
80 description="Loading engine",
81 total=None,
82 detail=snap.detail or "",
83 )
84 elif snap.phase is WarmPhase.READY:
85 total = snap.bytes_total or None
86 progress.update(task_id, description="Chat model ready", total=total, completed=total or 0)
87 elif snap.phase is WarmPhase.ERROR:
88 progress.update(task_id, description="Chat model failed to load", detail=snap.error or "")
89 else: # STARTING
90 progress.update(task_id, description="Preparing chat model", total=None, detail="")
93def render_warm(base_url: str, timeout_s: float) -> bool | None:
94 """Drive a progress display from the warm stream.
96 Returns ``True`` once the chat engine reports ready, ``False`` on an error
97 phase or if the stream ends before ready (the caller proceeds either way),
98 and ``None`` when the stream could not be used at all so the caller falls
99 back to a plain readiness poll.
100 """
101 columns = (
102 SpinnerColumn(),
103 TextColumn("[progress.description]{task.description}"),
104 BarColumn(),
105 DownloadColumn(),
106 TextColumn("{task.fields[detail]}"),
107 TimeElapsedColumn(),
108 )
109 saw_event = False
110 try:
111 with Progress(*columns, console=console, transient=True) as progress:
112 task_id = progress.add_task("Preparing chat model", total=None, detail="")
113 reached_ready = False
114 for snap in _iter_warm_events(base_url, timeout_s):
115 saw_event = True
116 _apply(progress, task_id, snap)
117 if snap.phase is WarmPhase.READY:
118 reached_ready = True
119 break
120 if snap.phase is WarmPhase.ERROR:
121 return False
122 return reached_ready
123 except httpx.HTTPError:
124 # Only None means "stream never ran, poll instead". If it opened and
125 # yielded events before dropping, report not-ready so the caller does not
126 # double-spend the full warm budget on a second poll.
127 return None if not saw_event else False