Coverage for src/lilbee/providers/fleet/binary.py: 100%
107 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Resolve the bundled engine binaries (llama-server, llama-swap, gguf-parser)."""
3from __future__ import annotations
5import functools
6import hashlib
7import shutil
8import subprocess
9from enum import StrEnum
10from importlib.metadata import version as _pkg_version
11from pathlib import Path
13from lilbee.providers.base import ProviderError, ProviderErrorKind
14from lilbee.providers.fleet.engine_install import engine_install_hint
16# --help exits from arg parsing, before any backend or model load, so this is
17# generous; a binary that cannot even print usage in this time is not one the
18# fleet should silently trust either way.
19_HELP_PROBE_TIMEOUT_S = 15.0
22class EngineTool(StrEnum):
23 """A bundled engine executable resolved from the ``lilbee-engine`` wheel."""
25 LLAMA_SERVER = "llama-server"
26 LLAMA_SWAP = "llama-swap"
27 GGUF_PARSER = "gguf-parser"
30_BUNDLED_ACCESSORS = {
31 EngineTool.LLAMA_SERVER: "get_llama_server_path",
32 EngineTool.LLAMA_SWAP: "get_llama_swap_path",
33 EngineTool.GGUF_PARSER: "get_gguf_parser_path",
34}
37def _bundled_tool(tool: EngineTool) -> Path | None:
38 """Path to *tool* from the ``lilbee-engine`` wheel, or ``None`` if absent."""
39 try:
40 import lilbee_engine
41 except ImportError:
42 return None
43 accessor = getattr(lilbee_engine, _BUNDLED_ACCESSORS[tool], None)
44 if accessor is None: # a wheel that predates this tool lacks the accessor
45 return None
46 path = Path(accessor())
47 return path if path.is_file() else None
50def engine_pin() -> str:
51 """Identity of the engine this lilbee would spawn; sharing keys on it.
53 Two dimensions must match for two processes to share one engine: the engine
54 BUILD (a configured ``LILBEE_LLAMA_SERVER_PATH`` is its own identity so a
55 bring-your-own engine never silently shares with a bundled one) and the
56 revision of the llama-swap config the engine runs under. A process whose pin
57 differs has ``contract_matches`` refuse the bind and overflows to its own
58 engine. The settings a launch bakes in are matched per launch instead
59 (``InstanceLaunch.settings``). Total: never raises, because it runs on every
60 state write.
61 """
62 # circular: swap_config -> binary via readback.engine_help
63 from lilbee.providers.fleet.swap_config import SWAP_CONFIG_REVISION
65 return f"{engine_build_id()}|swap-config={SWAP_CONFIG_REVISION}"
68def engine_build_id() -> str:
69 """The engine build's identity: configured path, wheel pin, PATH, or unpinned.
71 A BYO (``custom:``) or PATH-resolved (``path:``) binary is identified by its
72 location AND a cheap build fingerprint (size + mtime), so replacing the binary
73 in place (a brew upgrade, a re-download) changes the pin and never binds a new
74 process to an engine spawned from the old build. The bundled wheel needs no
75 fingerprint: its pin already encodes the build.
76 """
77 from lilbee.core.config import cfg
79 if cfg.llama_server_path:
80 return f"custom:{cfg.llama_server_path}@{_binary_signature(Path(cfg.llama_server_path))}"
81 try:
82 import lilbee_engine
83 except ImportError:
84 lilbee_engine = None
85 if lilbee_engine is not None:
86 try:
87 return str(lilbee_engine.get_engine_pin())
88 except AttributeError: # pre-pin wheels lack the accessor
89 return f"wheel:{_engine_wheel_version()}"
90 found = shutil.which(EngineTool.LLAMA_SERVER.value)
91 if found is not None:
92 return f"path:{found}@{_binary_signature(Path(found))}"
93 return "unpinned"
96def _engine_wheel_version() -> str:
97 """The engine wheel's version, or a marker when it has no distribution metadata.
99 ``lilbee_engine`` can be importable with nothing to look up: an extracted
100 wheel on sys.path, a vendored copy, or a distribution registered under a name
101 that does not normalize to ``lilbee-engine``. Since this feeds the pin, and
102 the pin is computed on every state write, a missing version degrades to a
103 marker rather than raising out of ``engine_pin``.
104 """
105 from importlib.metadata import PackageNotFoundError
107 try:
108 return _pkg_version("lilbee-engine")
109 except PackageNotFoundError:
110 return "unknown"
113def _binary_signature(path: Path) -> str:
114 """A cheap build fingerprint of the binary at *path*: size and mtime.
116 An in-place replacement changes both, so the pin stops matching the old build.
117 Best-effort and total (engine_pin runs on every state write): an unstatable
118 path degrades to a fixed marker rather than raising.
119 """
120 try:
121 st = path.stat()
122 except OSError:
123 return "unstatable"
124 return f"{st.st_size}-{st.st_mtime_ns}"
127def engine_binary_identity(binary: Path) -> str:
128 """Identity of the engine file at *binary*: its location and a digest of its bytes.
130 The file rather than the wheel, because a stub wheel can carry the version of
131 the real one; only the bytes that answered a probe identify what answered it.
132 The bytes rather than the stat fields ``engine_pin`` matches on: an upgrade
133 rewrites the file in place, the rewrite keeps the inode, and a build of the
134 same size written inside one mtime tick moves no stat field at all, so
135 metadata can repeat across a real engine change. ``llama-server`` is a
136 launcher of a few tens of kilobytes that loads the backend libraries at run
137 time, and this is read once per planning pass, so the digest costs far less
138 than the device probe it decides to re-run.
139 """
140 try:
141 with binary.open("rb") as handle:
142 return f"{binary}@{hashlib.file_digest(handle, 'sha256').hexdigest()}"
143 except OSError:
144 return f"{binary}@unreadable"
147def resolve_engine_tool(tool: EngineTool) -> Path:
148 """Resolve *tool*: configured llama-server path, then bundled wheel, then PATH.
150 Never downloads anything; the binaries arrive via the bundled ``lilbee-engine``
151 wheel or bring-your-own. Only llama-server honors ``LILBEE_LLAMA_SERVER_PATH``
152 (an explicit setting beats the bundled wheel); the other tools resolve from the
153 wheel, then ``PATH``.
154 """
155 if tool is EngineTool.LLAMA_SERVER:
156 from lilbee.core.config import cfg
158 if cfg.llama_server_path:
159 configured = Path(cfg.llama_server_path)
160 if not configured.is_file():
161 raise ProviderError(f"LILBEE_LLAMA_SERVER_PATH is not a file: {configured}")
162 return configured
164 bundled = _bundled_tool(tool)
165 if bundled is not None:
166 return bundled
168 found = shutil.which(tool.value)
169 if found is not None:
170 return Path(found)
172 # Only llama-server carries NOT_FOUND: it marks the engine-less host that
173 # legitimately serves nothing. A missing sibling tool (gguf-parser) must not
174 # take that kind, or the sizing fallback would misreport it as a model that
175 # isn't installed.
176 kind = (
177 ProviderErrorKind.NOT_FOUND
178 if tool is EngineTool.LLAMA_SERVER
179 else ProviderErrorKind.UNKNOWN
180 )
181 raise ProviderError(f"{tool.value} binary not found. {engine_install_hint()}", kind=kind)
184def resolve_llama_server() -> Path:
185 """Resolve the ``llama-server`` executable."""
186 return resolve_engine_tool(EngineTool.LLAMA_SERVER)
189def resolve_llama_swap() -> Path:
190 """Resolve the ``llama-swap`` executable."""
191 return resolve_engine_tool(EngineTool.LLAMA_SWAP)
194def resolve_gguf_parser() -> Path:
195 """Resolve the ``gguf-parser`` executable."""
196 return resolve_engine_tool(EngineTool.GGUF_PARSER)
199def llama_server_runtime_env() -> dict[str, str]:
200 """Extra environment for a spawned ``llama-server``.
202 The bundled wheel ships its own ggml/llama/mtmd next to the binary with a baked
203 rpath (``@loader_path`` on macOS, ``$ORIGIN`` on Linux), but a CUDA build also
204 links the CUDA 12 runtime, which driver-only GPU images omit. On Linux this
205 adds any installed CUDA-runtime wheel libs to ``LD_LIBRARY_PATH``; elsewhere it
206 is empty.
207 """
208 from lilbee.providers.fleet.cuda_runtime import cuda_runtime_env
210 return cuda_runtime_env()
213@functools.lru_cache(maxsize=8)
214def engine_help(binary: Path) -> str:
215 """The usage text *binary* prints for ``--help``, stdout then stderr, read once per path.
217 Empty when the binary cannot run or answer in time: every caller reads an
218 absent flag as unsupported, so a launch never carries a flag it did not see.
219 """
220 from lilbee.providers.fleet.gpu_env import engine_environ
222 try:
223 done = subprocess.run( # noqa: S603 - argv is the resolved engine binary and a literal
224 [str(binary), "--help"],
225 env=engine_environ(),
226 capture_output=True,
227 encoding="utf-8",
228 errors="replace",
229 timeout=_HELP_PROBE_TIMEOUT_S,
230 check=False,
231 )
232 except (OSError, subprocess.SubprocessError):
233 return ""
234 return f"{done.stdout}\n{done.stderr}"
237def help_lists_option(help_text: str, flag: str) -> bool:
238 """Whether an option line of *help_text* names *flag*.
240 An option line starts with ``-`` and names its flags up to the first word
241 that is not one, so a flag quoted in another option's description does not count.
242 """
243 for line in help_text.splitlines():
244 if not line.startswith("-"):
245 continue
246 for token in line.split():
247 if not token.startswith("-"):
248 break
249 if token.rstrip(",") == flag:
250 return True
251 return False