Coverage for src/lilbee/providers/base.py: 100%

118 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Base protocol and exceptions for LLM providers.""" 

2 

3from __future__ import annotations 

4 

5import math 

6from collections.abc import Callable, Iterator 

7from dataclasses import dataclass 

8from enum import StrEnum 

9from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, overload, runtime_checkable 

10 

11from pydantic import BaseModel 

12 

13from lilbee.core.health_warnings import HealthWarning 

14from lilbee.core.vectors import Vector 

15from lilbee.providers.roles import WorkerRole 

16from lilbee.runtime.cancellation import CancelSignal 

17 

18if TYPE_CHECKING: 

19 from lilbee.providers.warm_progress import WarmProgress 

20 

21T_co = TypeVar("T_co", covariant=True) 

22 

23# The inline reasoning markers lilbee's pipeline speaks. A provider whose server 

24# extracts reasoning into a separate field re-inlines it with these tags at the 

25# client boundary, so every downstream consumer parses one format. 

26# What every provider holds back from the context window for the answer it is 

27# about to generate, plus a small margin for the chat template's own framing. 

28# One owner for both numbers: the fleet ENFORCES this default budget (rejecting a 

29# prompt that exceeds it), while retrieval FITS its context to it. An explicit 

30# over-large output reservation (num_predict past the default) is clamped back to 

31# this default rather than rejected, so an agent that over-reserves still fits. 

32# When the two disagreed, retrieval assembled prompts up to the margin larger than 

33# the engine would accept, and a grounded turn failed with a 400 the caller could 

34# do nothing about. 

35GENERATION_RESERVE_TOKENS = 1024 

36CONTEXT_WINDOW_MARGIN_TOKENS = 128 

37# Per-message tokens the chat template adds around each message. One owner: 

38# the fleet windowing ENFORCES it and retrieval BUDGETS it, so a prompt 

39# retrieval assembles fits the check the provider runs. 

40CHAT_MESSAGE_OVERHEAD_TOKENS = 8 

41 

42# Chars-per-token assumed when BUDGETING context, deliberately harsher than the 

43# display estimator's 4: dense OCR/legal text tokenizes at ~2.5-3 chars per token. 

44BUDGET_CHARS_PER_TOKEN = 3 

45 

46 

47def prompt_token_budget(ctx: int, num_predict: int | None = None) -> int: 

48 """Tokens a prompt may occupy in a *ctx*-token window, reserve and margin removed.""" 

49 return ctx - (num_predict or GENERATION_RESERVE_TOKENS) - CONTEXT_WINDOW_MARGIN_TOKENS 

50 

51 

52def estimate_budget_tokens(text: str) -> int: 

53 """Conservative token cost of *text* for budgeting (see BUDGET_CHARS_PER_TOKEN).""" 

54 return max(1, math.ceil(len(text) / BUDGET_CHARS_PER_TOKEN)) 

55 

56 

57THINK_OPEN_TAG = "<think>" 

58THINK_CLOSE_TAG = "</think>" 

59 

60 

61@runtime_checkable 

62class ClosableIterator(Iterator[T_co], Protocol[T_co]): 

63 """An iterator that releases resources when ``close()`` is called. 

64 

65 Streaming chat responses use this to guarantee upstream resources (the 

66 fleet's in-flight request slot) are released even when callers truncate 

67 the stream before exhaustion. Generators satisfy this implicitly. 

68 """ 

69 

70 def close(self) -> None: ... 

71 

72 

73class LLMOptions(BaseModel): 

74 """Validated options passed to LLM providers. 

75 Only these fields are forwarded: everything else is rejected 

76 to prevent injection of sensitive parameters like api_base or api_key. 

77 """ 

78 

79 temperature: float | None = None 

80 top_p: float | None = None 

81 top_k: int | None = None 

82 seed: int | None = None 

83 num_predict: int | None = None 

84 repeat_penalty: float | None = None 

85 frequency_penalty: float | None = None 

86 presence_penalty: float | None = None 

87 num_ctx: int | None = None 

88 stop: list[str] | None = None 

89 # Thinking-template control for structured internal calls (schema 

90 # induction and similar): a small thinking model can burn its whole 

91 # token budget inside <think> and emit nothing. llama-server maps this 

92 # to chat_template_kwargs and Ollama to its think field; hosted-API translators drop it. 

93 think: bool | None = None 

94 # Structured-output request for the internal calls that want a bare JSON 

95 # value back. Must be listed here or the allowlist above silently drops it. 

96 response_format: dict[str, Any] | None = None 

97 # False makes llama-server evaluate the whole prompt, so a seeded reply does not 

98 # follow from what its slots hold. The SDK translator drops it for every other provider. 

99 cache_prompt: bool | None = None 

100 

101 def to_dict(self) -> dict[str, Any]: 

102 """Return only non-None values as a dict.""" 

103 return {k: v for k, v in self.model_dump().items() if v is not None} 

104 

105 

106def filter_options(options: dict[str, Any]) -> dict[str, Any]: 

107 """Validate and filter generation options through LLMOptions model.""" 

108 return LLMOptions(**options).to_dict() 

109 

110 

111class ChosenSeed(int): 

112 """A seed lilbee picked for an internal call, not one the user set. 

113 

114 A provider with no seed field drops it and answers the call. A seed that is 

115 a plain ``int`` came from the user, and that provider refuses it. 

116 """ 

117 

118 

119def aux_options(num_predict: int, **extra: Any) -> dict[str, Any]: 

120 """Options for an internal call: a cap sized for its answer, with thinking off. 

121 

122 A thinking model spends a small cap inside <think>, which ``strip_reasoning`` 

123 then deletes whole, so the call returns nothing. 

124 """ 

125 return {"num_predict": num_predict, "think": False, **extra} 

126 

127 

128def normalize_generation_options(options: dict[str, Any] | None) -> dict[str, Any]: 

129 """Validate options and map them to the per-call set an OpenAI/llama-server body takes. 

130 

131 ``filter_options`` validates against :class:`LLMOptions`; ``num_predict`` then 

132 becomes ``max_tokens`` and ``num_ctx`` is dropped (a model-load param, not a 

133 per-call one). Shared by the fleet and SDK option translators so the mapping 

134 lives in one place. 

135 """ 

136 if not options: 

137 return {} 

138 filtered = filter_options(options) 

139 if "num_predict" in filtered: 

140 filtered["max_tokens"] = filtered.pop("num_predict") 

141 filtered.pop("num_ctx", None) 

142 return filtered 

143 

144 

145class ProviderErrorKind(StrEnum): 

146 """Provider-agnostic category of a failed provider call. 

147 

148 Classified by exception type at each backend boundary so callers can 

149 branch on the kind instead of matching message strings (which are 

150 provider-specific and drift between SDK versions). 

151 """ 

152 

153 AUTH = "auth" 

154 RATE_LIMIT = "rate_limit" 

155 CONTEXT_OVERFLOW = "context_overflow" 

156 NOT_FOUND = "not_found" 

157 BAD_REQUEST = "bad_request" 

158 CONNECTION = "connection" 

159 SERVER = "server" 

160 CAPACITY = "capacity" 

161 PORT_CONFLICT = "port_conflict" 

162 MODEL_FILE_BROKEN = "model_file_broken" 

163 UNKNOWN = "unknown" 

164 

165 

166class ProviderError(Exception): 

167 """Raised when an LLM provider operation fails. 

168 

169 ``kind`` is the provider-agnostic category; backends that can't classify a 

170 failure leave it ``UNKNOWN``. 

171 """ 

172 

173 def __init__( 

174 self, 

175 message: str, 

176 *, 

177 provider: str = "", 

178 kind: ProviderErrorKind = ProviderErrorKind.UNKNOWN, 

179 ) -> None: 

180 self.provider = provider 

181 self.kind = kind 

182 super().__init__(message) 

183 

184 

185# Human word per role for the not-configured error ("embedding model", not "embed model"). 

186_ROLE_WORDS: dict[WorkerRole, str] = { 

187 WorkerRole.CHAT: "chat", 

188 WorkerRole.EMBED: "embedding", 

189 WorkerRole.RERANK: "reranker", 

190 WorkerRole.VISION: "vision", 

191} 

192 

193 

194def require_role_ref(ref: str, role: WorkerRole, *, provider: str = "") -> str: 

195 """Reject an unconfigured role with a clean error instead of parsing ''.""" 

196 if not ref: 

197 raise ProviderError( 

198 f"No {_ROLE_WORDS[role]} model is configured. Pick one from the catalog " 

199 f"or run 'lilbee model pull <model>'.", 

200 provider=provider, 

201 kind=ProviderErrorKind.NOT_FOUND, 

202 ) 

203 return ref 

204 

205 

206ChatMessage = dict[str, str] 

207 

208 

209@dataclass(frozen=True) 

210class ToolCall: 

211 """One tool/function call the model requested. 

212 

213 ``arguments`` is the raw JSON-encoded argument object (OpenAI's shape), left 

214 as a string so the caller decides how to parse and validate it. ``id`` is the 

215 server-assigned call id, echoed back in the tool result message. 

216 """ 

217 

218 id: str 

219 name: str 

220 arguments: str 

221 

222 

223@dataclass(frozen=True) 

224class ChatToolResult: 

225 """A chat turn that may carry tool calls alongside (or instead of) text. 

226 

227 ``tool_calls`` is empty for an ordinary text answer; ``content`` is empty when 

228 the model returned only tool calls. Both can be populated when a model emits 

229 commentary plus a call. 

230 """ 

231 

232 content: str 

233 tool_calls: list[ToolCall] 

234 

235 

236class FinishReason(StrEnum): 

237 """Why a chat completion stopped, mirroring OpenAI's vocabulary.""" 

238 

239 STOP = "stop" 

240 LENGTH = "length" 

241 TOOL_CALLS = "tool_calls" 

242 CONTENT_FILTER = "content_filter" 

243 

244 @classmethod 

245 def coerce(cls, raw: object) -> FinishReason: 

246 """Map a backend-supplied finish_reason to a member, defaulting to STOP. 

247 

248 Both the streaming and non-streaming paths read finish_reason from the 

249 backend; an unknown or non-string value (a model that omits it) falls 

250 back to STOP so the dispatch reports an ordinary end-of-turn. 

251 """ 

252 if isinstance(raw, str): 

253 try: 

254 return cls(raw) 

255 except ValueError: 

256 return cls.STOP 

257 return cls.STOP 

258 

259 

260@dataclass(frozen=True) 

261class TokenUsage: 

262 """Prompt / completion token counts for one chat call. 

263 

264 Defaults to zero so a backend that reports no usage block still yields a 

265 well-formed result; the fleet populates these from llama-server's ``usage``. 

266 """ 

267 

268 prompt_tokens: int = 0 

269 completion_tokens: int = 0 

270 # Prompt tokens the engine served from cache, a subset of prompt_tokens. 

271 cached_prompt_tokens: int = 0 

272 

273 

274@dataclass(frozen=True) 

275class ChatResult: 

276 """Structured result from a non-streaming chat call. 

277 

278 ``tool_calls`` is empty for an ordinary text answer; ``text`` is empty when 

279 the model returned only tool calls. ``usage`` carries the backend's token 

280 counts (zero when unreported). The canonical chat dispatch reads these to 

281 build its OpenAI/Anthropic-shaped response. 

282 """ 

283 

284 text: str 

285 tool_calls: tuple[ToolCall, ...] 

286 finish_reason: FinishReason 

287 usage: TokenUsage = TokenUsage() 

288 

289 

290@dataclass(frozen=True) 

291class ToolCallDelta: 

292 """Partial tool-call delta in a streaming response, accumulated by ``index``. 

293 

294 ``id`` and ``name`` arrive on the opener frame for a call; ``arguments_delta`` 

295 accumulates across subsequent frames at the same ``index``. 

296 """ 

297 

298 index: int 

299 id: str | None 

300 name: str | None 

301 arguments_delta: str | None 

302 

303 

304@dataclass(frozen=True) 

305class StreamFinish: 

306 """Terminal frame carrying why a streaming chat call stopped. 

307 

308 Emitted once, near the end of the stream, so the dispatch can report the 

309 same finish_reason the non-streaming path already surfaces, notably 

310 ``length`` on a max_tokens truncation. Tool-call streams already infer 

311 TOOL_USE from their deltas, so a finish frame never downgrades that. 

312 """ 

313 

314 reason: FinishReason 

315 

316 

317ChatStreamItem = str | ToolCallDelta | TokenUsage | StreamFinish 

318"""One frame yielded by a streaming chat call: text token, tool-call delta, the 

319final token-usage summary, or the finish-reason terminator (each emitted once, 

320last, when the backend reports them).""" 

321 

322 

323class LLMProvider(Protocol): 

324 """Protocol for pluggable LLM backends.""" 

325 

326 def embed(self, texts: list[str]) -> list[Vector]: 

327 """Embed a batch of texts, return list of vectors.""" 

328 ... 

329 

330 def count_tokens(self, text: str) -> int: 

331 """Exact token count of *text* under the embedding model's tokenizer. 

332 

333 Raise ``NotImplementedError`` when the backend has no local tokenizer (cloud 

334 SDK backends); token-budgeted chunk sizing then falls back to a character 

335 estimate. 

336 """ 

337 ... 

338 

339 def count_chat_prompt_tokens( 

340 self, 

341 messages: list[ChatMessage], 

342 *, 

343 options: dict[str, Any] | None = None, 

344 model: str | None = None, 

345 tools: list[dict[str, Any]] | None = None, 

346 tool_choice: str | dict[str, Any] | None = None, 

347 ) -> int: 

348 """Tokens the chat model prefills for the prompt :meth:`chat` would send. 

349 

350 Takes the same arguments as :meth:`chat`. The model's chat template is 

351 applied first, so role markers and the tool-call preamble are counted. 

352 Assumes no warm state: a backend whose chat model is cold loads it, and 

353 the first call against a fresh replica may issue the same one-token 

354 template probe :meth:`chat` issues, so this can block for a cold start. 

355 Raise ``NotImplementedError`` when the backend cannot render or 

356 tokenize, and the caller estimates instead. 

357 """ 

358 ... 

359 

360 @overload 

361 def chat( 

362 self, 

363 messages: list[ChatMessage], 

364 *, 

365 stream: Literal[False] = False, 

366 options: dict[str, Any] | None = None, 

367 model: str | None = None, 

368 tools: list[dict[str, Any]] | None = None, 

369 tool_choice: str | dict[str, Any] | None = None, 

370 ) -> ChatResult: ... 

371 

372 @overload 

373 def chat( 

374 self, 

375 messages: list[ChatMessage], 

376 *, 

377 stream: Literal[True], 

378 options: dict[str, Any] | None = None, 

379 model: str | None = None, 

380 tools: list[dict[str, Any]] | None = None, 

381 tool_choice: str | dict[str, Any] | None = None, 

382 ) -> ClosableIterator[ChatStreamItem]: ... 

383 

384 def chat( 

385 self, 

386 messages: list[ChatMessage], 

387 *, 

388 stream: bool = False, 

389 options: dict[str, Any] | None = None, 

390 model: str | None = None, 

391 tools: list[dict[str, Any]] | None = None, 

392 tool_choice: str | dict[str, Any] | None = None, 

393 ) -> ChatResult | ClosableIterator[ChatStreamItem]: 

394 """Chat completion. 

395 

396 Non-streaming returns a :class:`ChatResult` (assistant text, any 

397 tool-call frames, and a finish reason). Streaming returns a 

398 :class:`ClosableIterator` of :data:`ChatStreamItem` (text tokens 

399 interleaved with :class:`ToolCallDelta` frames). ``tools`` is the 

400 OpenAI function-tool list; ``tool_choice`` is ``"auto"`` / ``"none"`` / 

401 ``"required"`` or a ``{"type": "function", ...}`` selector. A model 

402 that lacks tool support returns an empty ``tool_calls`` / yields no 

403 tool deltas rather than erroring. 

404 """ 

405 ... 

406 

407 def supports_tools(self, model_ref: str) -> bool: 

408 """Return True iff the backend can route tool calls for *model_ref*. 

409 

410 Default False so backends without a tool path are never offered tools; 

411 tool-capable backends override this with a real probe. 

412 """ 

413 return False 

414 

415 def chat_with_tools( 

416 self, 

417 messages: list[ChatMessage], 

418 *, 

419 tools: list[dict[str, Any]], 

420 tool_choice: str | dict[str, Any] | None = None, 

421 options: dict[str, Any] | None = None, 

422 model: str | None = None, 

423 ) -> ChatToolResult: 

424 """Non-streaming chat that may return tool calls. 

425 

426 ``tools`` is the OpenAI function-tool list; ``tool_choice`` is ``"auto"`` 

427 / ``"none"`` / ``"required"`` or a specific ``{"type": "function", ...}`` 

428 selector. Backends without tool support raise :class:`ProviderError`. 

429 """ 

430 raise ProviderError("This backend does not support tool calling.") 

431 

432 def vision_ocr( 

433 self, 

434 png_bytes: bytes, 

435 model: str, 

436 prompt: str = "", 

437 *, 

438 timeout: float | None = None, 

439 cancel: CancelSignal | None = None, 

440 ) -> str: 

441 """OCR one page image; ``timeout`` seconds, ``None``/``0`` = no cap. 

442 

443 A set *cancel* raises ``TaskCancelledError`` instead of starting the page. 

444 """ 

445 ... 

446 

447 def vision_slot_capacity(self) -> int | None: 

448 """Fitted concurrent-OCR slots if the vision fleet is running, else None. 

449 

450 The ingest fan-out uses this to size itself to the servers' real 

451 continuous-batching capacity rather than the requested concurrency, 

452 which a memory-constrained card cannot always fit. ``None`` means the 

453 capacity isn't known yet (no local vision backend, or the fleet hasn't 

454 started); the caller falls back to its own estimate. 

455 """ 

456 ... 

457 

458 def list_models(self) -> list[str]: 

459 """List available model identifiers.""" 

460 ... 

461 

462 def list_chat_models(self, provider: str) -> list[str]: 

463 """List frontier chat models the provider is aware of for *provider*. 

464 

465 Returns the unfiltered upstream catalog (whatever the SDK 

466 exposes for API providers; an empty list for local backends 

467 like the llama-server fleet that have no notion of external 

468 catalogs). 

469 """ 

470 ... 

471 

472 def pull_model(self, model: str, *, on_progress: Callable[..., Any] | None = None) -> None: 

473 """Download a model. Raises NotImplementedError if not supported.""" 

474 ... 

475 

476 def show_model(self, model: str) -> dict[str, Any] | None: 

477 """Return model metadata, or None if backend doesn't expose it.""" 

478 ... 

479 

480 def get_capabilities(self, model: str) -> list[str]: 

481 """Return capability tags (e.g. ``["completion", "vision"]``) for *model*. 

482 

483 Returns an empty list when the backend does not support capability 

484 reporting or the model is not found. 

485 """ 

486 ... 

487 

488 def rerank(self, query: str, candidates: list[str]) -> list[float]: 

489 """Score *candidates* for their relevance to *query*, one float per candidate. 

490 

491 The backend resolves the reranker model from ``cfg.reranker_model``. 

492 Callers MUST check ``cfg.reranker_model`` is non-empty before 

493 calling; use :meth:`supports_rerank` for UI-render decisions. 

494 

495 Returns: list of floats in input order, higher = more relevant. 

496 Empty ``candidates`` returns ``[]``. 

497 Raises :class:`ProviderError` when the backend does not support 

498 reranking, ``cfg.reranker_model`` is empty, or the model scored no 

499 candidate. A backend must raise rather than return uniform scores, 

500 which would silently preserve the caller's input order. 

501 """ 

502 ... 

503 

504 def supports_rerank(self) -> bool: 

505 """Capability probe: can this backend rerank *if* a model is configured? 

506 

507 Pure capability check, NOT "a reranker is currently active". An 

508 empty ``cfg.reranker_model`` returns ``True`` so the settings UI 

509 keeps the picker visible; callers that need to know whether 

510 reranking is actually configured must check ``bool(cfg.reranker_model)`` 

511 separately. ``rerank()`` is the gated path that requires a 

512 non-empty value. 

513 """ 

514 return False 

515 

516 def shutdown(self) -> None: 

517 """Release resources (e.g. background threads). No-op if nothing to clean up.""" 

518 ... 

519 

520 def warm_up_pool(self) -> None: 

521 """Eagerly start the configured role servers so the first call lands warm. 

522 

523 Default no-op so providers without managed servers (SDK / routing 

524 wrappers) can be passed to ``Services`` unchanged. Implemented by 

525 :class:`FleetProvider` to spawn the chat / embed / rerank / vision 

526 servers whose model is configured. 

527 """ 

528 return 

529 

530 def cancel_inference(self) -> None: 

531 """Interrupt any in-flight generation. No-op default. 

532 

533 The fleet engine severs its live chat streams (llama-server stops 

534 generating when the connection drops); the SDK wrapper has nothing to 

535 interrupt here. 

536 """ 

537 return 

538 

539 def reload_role(self, role: WorkerRole, *, wait: bool = False) -> None: 

540 """Drop and respawn just *role*'s model so it picks up changed cfg. 

541 

542 Default no-op for providers without per-role model servers. The fleet 

543 respawns only that role's server; other roles and their in-flight work 

544 are left untouched. ``wait=True`` blocks until the respawn finishes (for a 

545 caller already off the event loop); the default returns immediately. 

546 """ 

547 return 

548 

549 def replan_fleet(self, *, wait: bool = False) -> None: 

550 """Re-plan the fleet from current cfg, restarting only moved roles. 

551 

552 Default no-op for providers without GPU-placed servers. The fleet diffs 

553 the fresh plan against the running fleet and respawns only the roles 

554 whose launch changed, so an untouched role's loaded model stays 

555 resident. ``wait=True`` blocks until the restarted proxies are healthy. 

556 """ 

557 return 

558 

559 def role_ready(self, role: WorkerRole) -> bool: 

560 """Whether *role* has a healthy server now, without starting one. 

561 

562 Default ``True``: providers without managed servers (SDK / routing 

563 wrappers) are always reachable. The fleet returns ``False`` while a role 

564 is still cold-starting so surfaces can show a warming state. 

565 """ 

566 del role 

567 return True 

568 

569 def max_concurrent_chats(self) -> int: 

570 """Upper bound on simultaneous chat generations this provider can serve. 

571 

572 Default ``1``: a single in-process model cannot take concurrent generate 

573 calls, so chat is serialized. A server-backed provider that batches (the 

574 fleet) overrides this with its slot capacity, so the chat admission gate 

575 lets that many run at once instead of one at a time. 

576 """ 

577 return 1 

578 

579 def served_chat_ctx(self) -> int | None: 

580 """Per-slot context the active chat server runs with, or None if unknown. 

581 

582 A client trims its conversation to this so a long agentic session fits 

583 the model's actual window instead of overflowing. Default ``None``: 

584 providers without a managed context (SDK wrappers) advertise nothing. 

585 """ 

586 return None 

587 

588 def served_chat_slots(self) -> int | None: 

589 """Batching slots the active chat server runs with, or None if unknown. 

590 

591 Unlike :meth:`max_concurrent_chats` (an admission bound that must always 

592 yield a number), this reports the granted shape and stays ``None`` until 

593 a managed engine is up, so status surfaces can tell "one slot" apart 

594 from "no engine yet". 

595 """ 

596 return None 

597 

598 def embed_token_cap(self) -> int | None: 

599 """Tokens the embedder truncates one input to, or None when it has no fixed cap. 

600 

601 The chunker bounds its budget to this so no chunk loses text at embedding 

602 time. Default ``None``: a remote embedder advertises no window. 

603 """ 

604 return None 

605 

606 def health_warnings(self) -> list[HealthWarning]: 

607 """Serving degradations a client should know about. Default: none.""" 

608 return [] 

609 

610 def chat_prefill_progress(self) -> tuple[int, int] | None: 

611 """``(processed, total)`` prompt tokens of a chat prefill in flight, or None. 

612 

613 A large model's first agent turn can spend minutes in prompt processing 

614 with no tokens streamed; status surfaces poll this to show the work. 

615 Default ``None``: providers without a managed engine report nothing. 

616 """ 

617 return None 

618 

619 def warm_pending(self) -> bool: 

620 """Whether a warm has been requested and has not finished. 

621 

622 True from the moment ``warm_up_pool`` accepts a warm until its background 

623 work ends, so a surface can hold before the first phase is stamped. Default 

624 ``False``: providers without managed servers never warm. 

625 """ 

626 return False 

627 

628 def warm_progress(self) -> WarmProgress | None: 

629 """Snapshot of the chat model's cold-load progress, or None when idle. 

630 

631 A launcher streams this to render a real progress bar while a large chat 

632 model loads. Default ``None``: providers without a managed load (SDK / 

633 routing wrappers) expose nothing, so a launcher falls back to a plain 

634 spinner. The fleet returns live read / engine-load state. 

635 """ 

636 return None 

637 

638 def add_spawn_listener( 

639 self, 

640 *, 

641 on_spawning: Callable[[WorkerRole], None] | None = None, 

642 on_spawned: Callable[[WorkerRole], None] | None = None, 

643 ) -> None: 

644 """Subscribe to server (re)spawn lifecycle events. No-op default. 

645 

646 The fleet calls ``on_spawning`` before a role's server starts and 

647 ``on_spawned`` once it is healthy, so the TUI can surface cold-start and 

648 reload progress. Providers without managed servers ignore it. 

649 """ 

650 return