Coverage for src/lilbee/providers/warm_progress.py: 100%
55 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"""Server-side view of the chat model's cold load, for granular launch feedback.
3A launcher streams this while a chat model loads so the user sees real progress
4(reading weights with a true byte percentage, then the engine load) instead of a
5frozen "Warming..." line. The fleet provider drives the tracker through its warm
6path; providers without a managed load report nothing and a launcher falls back
7to a plain spinner.
8"""
10from __future__ import annotations
12import threading
13import time
14from enum import StrEnum
16from pydantic import BaseModel
19class WarmPhase(StrEnum):
20 """The stage a chat-model cold load is in."""
22 STARTING = "starting"
23 """Warm thread has begun; the engine has not been touched yet."""
24 READING_WEIGHTS = "reading_weights"
25 """Paging the GGUF shards off disk into the page cache; reports a true byte %."""
26 LOADING_ENGINE = "loading_engine"
27 """The engine is loading the cached weights into VRAM; no byte signal, so
28 surfaces show an indeterminate spinner bounded by readiness."""
29 READY = "ready"
30 """The chat engine is loaded and can serve a first token."""
31 ERROR = "error"
32 """The load failed; ``error`` carries the user-facing reason."""
35class WarmProgress(BaseModel):
36 """A snapshot of the chat role's warm state, streamed to a launcher."""
38 phase: WarmPhase
39 model_ref: str | None = None
40 bytes_done: int = 0
41 bytes_total: int = 0
42 detail: str | None = None
43 error: str | None = None
44 elapsed_s: float = 0.0
47class WarmProgressTracker:
48 """Thread-safe warm-state holder: the warm thread writes, handlers read.
50 The fleet warm-up runs on a daemon thread while the health / SSE handlers
51 read concurrently, so every mutation and the snapshot read take the lock.
52 ``elapsed_s`` is stamped at read time from the ``begin`` monotonic mark so
53 callers always see a live elapsed without the writer ticking a clock.
54 """
56 def __init__(self) -> None:
57 self._lock = threading.Lock()
58 self._snapshot: WarmProgress | None = None
59 self._started_at: float | None = None
61 def begin(self, model_ref: str | None) -> None:
62 """Mark the start of a cold load; resets elapsed and clears prior state."""
63 with self._lock:
64 self._started_at = time.monotonic()
65 self._snapshot = WarmProgress(phase=WarmPhase.STARTING, model_ref=model_ref)
67 def reading(self, bytes_done: int, bytes_total: int, detail: str | None = None) -> None:
68 """Report read-phase progress in bytes."""
69 self._advance(
70 WarmPhase.READING_WEIGHTS,
71 bytes_done=bytes_done,
72 bytes_total=bytes_total,
73 detail=detail,
74 )
76 def loading_engine(self, detail: str | None = None) -> None:
77 """Mark the transition into the indeterminate VRAM-load phase."""
78 self._advance(WarmPhase.LOADING_ENGINE, detail=detail)
80 def ready(self) -> None:
81 """Mark the chat engine ready to serve."""
82 self._advance(WarmPhase.READY)
84 def fail(self, message: str) -> None:
85 """Mark the load as failed with a user-facing reason."""
86 self._advance(WarmPhase.ERROR, error=message)
88 def clear(self) -> None:
89 """Drop any recorded warm state."""
90 with self._lock:
91 self._snapshot = None
92 self._started_at = None
94 def snapshot(self) -> WarmProgress | None:
95 """Return a copy of the current state with live ``elapsed_s``, or None."""
96 with self._lock:
97 if self._snapshot is None:
98 return None
99 elapsed = time.monotonic() - self._started_at if self._started_at is not None else 0.0
100 return self._snapshot.model_copy(update={"elapsed_s": elapsed})
102 def _advance(
103 self,
104 phase: WarmPhase,
105 *,
106 bytes_done: int = 0,
107 bytes_total: int = 0,
108 detail: str | None = None,
109 error: str | None = None,
110 ) -> None:
111 with self._lock:
112 model_ref = self._snapshot.model_ref if self._snapshot is not None else None
113 self._snapshot = WarmProgress(
114 phase=phase,
115 model_ref=model_ref,
116 bytes_done=bytes_done,
117 bytes_total=bytes_total,
118 detail=detail,
119 error=error,
120 )