Coverage for src/lilbee/cli/launchers/launcher.py: 100%
44 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"""Launcher protocol and the orchestrator that runs any launcher."""
3from __future__ import annotations
5import subprocess
6from typing import Protocol
8import typer
10from lilbee.app.agent_configs.window import AGENT_CHAT_CTX_FLOOR, agent_chat_ctx_target
11from lilbee.app.models import installed_chat_model_refs
12from lilbee.cli.launchers.server import (
13 ensure_server_running,
14 served_chat_ctx,
15 stop_spawned_server,
16 wait_for_chat_warm,
17)
18from lilbee.core.config import cfg
19from lilbee.providers.model_ref import with_configured_remote_chat
21# The env var each launcher sets to the live session token; the written config
22# references it (opencode `{env:...}`, hermes `${...}`) so no literal lands on disk.
23LILBEE_TOKEN_ENV_VAR = "LILBEE_TOKEN" # noqa: S105 (env var name, not a secret)
25# Config key the spawned `lilbee serve` reads for its working chat window; a
26# LILBEE_ env var overrides config.toml, so the launcher raises it per launch.
27_CHAT_CTX_TARGET_ENV_VAR = "LILBEE_CHAT_N_CTX_TARGET"
30class Launcher(Protocol):
31 """A third-party AI client that lilbee knows how to launch."""
33 name: str
34 """CLI subcommand name; ``lilbee launch <name>`` runs this launcher."""
36 install_hint: str
37 """User-facing message shown when ``find_binary`` returns None."""
39 def find_binary(self) -> str | None:
40 """Return the absolute path to the client binary, or None if not installed."""
41 ...
43 def prepare(
44 self, *, token: str, port: int, model_refs: list[str]
45 ) -> tuple[list[str], dict[str, str]]:
46 """Return ``(extra_args, env)`` for the client invocation.
48 Side effects (skill installs, picker-state writes, config-file
49 materialization) happen here. The orchestrator does not introspect
50 them; whatever the launcher decides is the launcher's business.
51 """
52 ...
55def _warn_on_model_pin_gaps(model_refs: list[str]) -> None:
56 """Warn when the launched client cannot open on a lilbee-served chat model."""
57 if not model_refs:
58 # The client provider is written with no models, so it cannot use lilbee.
59 # Some clients (e.g. opencode) then silently fall back to their own default
60 # provider, so make the cause loud instead of leaving an empty picker.
61 typer.secho(
62 "Warning: no chat models are installed, so the launched client will have "
63 "no lilbee models to select. Pull one first, e.g. "
64 "'lilbee model pull Qwen/Qwen3-8B-GGUF'.",
65 err=True,
66 fg=typer.colors.YELLOW,
67 )
68 elif cfg.chat_model and str(cfg.chat_model) not in model_refs:
69 # The startup pin would point at a model the provider does not serve,
70 # so the client opens on its own default provider instead of lilbee.
71 typer.secho(
72 f"Warning: configured chat model '{cfg.chat_model}' is not installed; "
73 "the launched client will not open on a lilbee model. Pull it first "
74 "or set chat_model to an installed ref.",
75 err=True,
76 fg=typer.colors.YELLOW,
77 )
80def _warn_on_reused_small_window(port: int) -> None:
81 """Say when a reused server's window is below the agent floor, with the remedy.
83 The env override only reaches a freshly spawned ``lilbee serve``; a server
84 that was already running keeps the window it booted with, and this launch
85 cannot resize it. A cold chat engine reports no window yet, so nothing can
86 be checked then and the probe stays silent.
87 """
88 ctx = served_chat_ctx(port)
89 if ctx is None or ctx >= AGENT_CHAT_CTX_FLOOR:
90 return
91 typer.secho(
92 f"Warning: reusing the running lilbee server, which serves a {ctx:,}-token "
93 f"context window; an agent's first turn needs about {AGENT_CHAT_CTX_FLOOR:,}. "
94 "The window is fixed when the server starts. Stop that server, run "
95 "'lilbee engine stop', then re-run this command to start one sized for agents.",
96 err=True,
97 fg=typer.colors.YELLOW,
98 )
101def run_launcher(launcher: Launcher) -> None:
102 """Find the client, ensure a lilbee server is up, prepare, exec, clean up."""
103 binary = launcher.find_binary()
104 if binary is None:
105 typer.secho(launcher.install_hint, err=True, fg=typer.colors.RED)
106 raise typer.Exit(1)
107 # The launcher only reads the registry and talks to the spawned `lilbee serve`
108 # over HTTP; it runs no inference itself. Skip the eager warm so get_services()
109 # here doesn't start a second llama-swap that races the server's for the model
110 # port (the loser gets connection-refused). The spawned serve warms its own.
111 cfg.worker_pool_eager_start = False
112 # Size the served window for the agent before the server spawns: in-process so
113 # the warm wait and window warning agree, and via the child's env (LILBEE_
114 # overrides config.toml) so the spawned serve actually grows it.
115 agent_target = agent_chat_ctx_target(cfg.chat_n_ctx_target)
116 cfg.chat_n_ctx_target = agent_target
117 (token, port), spawned = ensure_server_running(
118 env_overrides={_CHAT_CTX_TARGET_ENV_VAR: str(agent_target)}
119 )
120 if spawned is None:
121 _warn_on_reused_small_window(port)
122 # Everything after the spawn runs under the finally so a raise from prepare()
123 # (e.g. the user declining opencode setup) or the warm wait can't leak the
124 # spawned `lilbee serve` process.
125 try:
126 native_refs = installed_chat_model_refs()
127 model_refs = with_configured_remote_chat(native_refs, cfg.chat_model)
128 _warn_on_model_pin_gaps(model_refs)
129 # Wait out the cold model load before handing off, so the client opens onto a
130 # warm engine instead of an apparently-dead stream. Only meaningful when a
131 # native chat model is installed to warm; a remote-configured model has no
132 # local load to wait for.
133 if native_refs:
134 wait_for_chat_warm(port)
135 extra_args, env = launcher.prepare(token=token, port=port, model_refs=model_refs)
136 # The client paints its own UI only after its runtime boots, a few silent
137 # seconds; announce the handoff so the warm bar isn't followed by a dead
138 # screen with no explanation.
139 typer.secho(f"Launching {launcher.name}...", fg=typer.colors.GREEN)
140 # binary resolved via the launcher's find_binary on PATH; no shell.
141 result = subprocess.run([binary, *extra_args], env=env, check=False) # noqa: S603
142 finally:
143 if spawned is not None:
144 stop_spawned_server(spawned)
145 raise typer.Exit(result.returncode)