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

1"""Launcher protocol and the orchestrator that runs any launcher.""" 

2 

3from __future__ import annotations 

4 

5import subprocess 

6from typing import Protocol 

7 

8import typer 

9 

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 

20 

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) 

24 

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" 

28 

29 

30class Launcher(Protocol): 

31 """A third-party AI client that lilbee knows how to launch.""" 

32 

33 name: str 

34 """CLI subcommand name; ``lilbee launch <name>`` runs this launcher.""" 

35 

36 install_hint: str 

37 """User-facing message shown when ``find_binary`` returns None.""" 

38 

39 def find_binary(self) -> str | None: 

40 """Return the absolute path to the client binary, or None if not installed.""" 

41 ... 

42 

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. 

47 

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 ... 

53 

54 

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 ) 

78 

79 

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. 

82 

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 ) 

99 

100 

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)