Coverage for src/lilbee/providers/fleet/swap_config.py: 100%
54 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"""Generate one swap group's llama-swap config.
3Each group runs behind its own llama-swap process with its own config, so a
4reload of one group never touches another's loaded servers. See
5docs/architecture.md (llama-swap) for the supervisor/proxy design.
6"""
8from __future__ import annotations
10import json
11import shlex
12import subprocess
13import sys
14from typing import TYPE_CHECKING
16from lilbee.data.offload import max_workers
17from lilbee.providers.fleet.readback import engine_log_env, writes_engine_log
18from lilbee.providers.roles import WorkerRole
20if TYPE_CHECKING:
21 from collections.abc import Mapping
22 from pathlib import Path
24 from lilbee.providers.fleet.launch import InstanceLaunch
26# One group holds this process's members. Swap disabled keeps them co-resident (a
27# role's replicas); enabled makes llama-swap evict one to load another.
28_GROUP_NAME = "lilbee"
29# Cold-load ceiling floor per role; the member's weights scale it up from here.
30# An embedder's weights stream in seconds, so its floor covers only process start
31# and backend init: waiting out a chat-sized ten minutes on a crash-looping or
32# wedged embed server holds every ingest caller for that long.
33_COLD_LOAD_FLOOR_S: dict[WorkerRole, int] = {
34 WorkerRole.CHAT: 600,
35 WorkerRole.VISION: 600,
36 WorkerRole.RERANK: 600,
37 WorkerRole.EMBED: 120,
38}
39# Conservative cold-load disk rate; a slow network volume streams well under this.
40_COLD_LOAD_BYTES_PER_S = 150 * 1024 * 1024
41_LOG_LEVEL = "info"
42# Matches the --host every server argv binds (adapters); "localhost" would have
43# llama-swap dial [::1] first, where another process could hold the same port.
44_PROXY_URL_TEMPLATE = "http://127.0.0.1:{port}"
45# Shared with the swap manager, whose orphan-server sweep matches this flag's
46# value in survivor cmdlines.
47PORT_FLAG = "--port"
49# llama-swap refuses a request with HTTP 429 once this many are in flight for
50# one model (its default is 10), and a request held through a cold load counts.
51# One engine serves every lilbee process the user runs (the TUI, serve with its
52# HTTP clients, MCP, an ingest run), and llama-server queues requests past its
53# slots by itself, so the limit only has to stop runaway load. It covers this
54# many processes, each at its own ceiling: the larger of the slots and the
55# ingest pool, whose every thread can hold one embed or OCR request.
56_CONCURRENCY_PROCESSES = 4
57# Part of the engine pin, so a peer never binds an engine whose config lacks a
58# key it relies on. 1: every model carries concurrencyLimit.
59SWAP_CONFIG_REVISION = 1
61# llama-swap config keys.
62_KEY_HEALTH_TIMEOUT = "healthCheckTimeout"
63_KEY_LOG_LEVEL = "logLevel"
64_KEY_MODELS = "models"
65_KEY_CMD = "cmd"
66_KEY_PROXY = "proxy"
67_KEY_TTL = "ttl"
68_KEY_CONCURRENCY_LIMIT = "concurrencyLimit"
69_KEY_ENV = "env"
70_KEY_GROUPS = "groups"
71_KEY_SWAP = "swap"
72_KEY_EXCLUSIVE = "exclusive"
73_KEY_PERSISTENT = "persistent"
74_KEY_MEMBERS = "members"
77def build_swap_config(
78 launches: list[InstanceLaunch],
79 member_ports: Mapping[str, int],
80 *,
81 swap: bool = False,
82 ttl_seconds: int = 0,
83 engine_log_dir: Path | None = None,
84) -> str:
85 """Render a llama-swap config (JSON, which is valid YAML) for *launches*.
87 Each launch becomes a model whose id is its replica model id and whose
88 command is the llama-server argv plus the explicit port from *member_ports*;
89 one group holds them behind this group's proxy endpoint. ``swap`` makes the
90 members evict each other on load, so only one is resident at a time; the
91 default keeps them co-resident. ``ttl_seconds`` is llama-swap's idle unload
92 timer per member; 0 keeps weights loaded forever. Ports are allocated fresh per start (never
93 llama-swap's fixed ``startPort`` range) so a previous instance's lingering
94 server can't collide with the new fleet's bind.
96 ``engine_log_dir`` points each engine at its own log there, at the verbosity
97 that reports what it allocated. llama-swap forwards none of the upstream's
98 output, so for an engine without ``GET /memory`` that file is the only place
99 those numbers exist, and reading them back is what tells the planner whether
100 its estimate held (:mod:`lilbee.providers.fleet.readback`). A launch carrying
101 ``--memory`` serves the same numbers over HTTP instead and gets no log env:
102 the trace-level file is exactly what the endpoint retires.
103 """
104 models: dict[str, object] = {}
105 for launch in launches:
106 port = member_ports[launch.model_id]
107 entry: dict[str, object] = {
108 _KEY_CMD: _command_line(launch.argv, port),
109 _KEY_PROXY: _PROXY_URL_TEMPLATE.format(port=port),
110 _KEY_TTL: ttl_seconds,
111 _KEY_CONCURRENCY_LIMIT: _concurrency_limit(launch),
112 }
113 env = dict(launch.env_overrides)
114 if engine_log_dir is not None and writes_engine_log(launch.argv):
115 env.update(engine_log_env(engine_log_dir, launch.model_id))
116 if env:
117 entry[_KEY_ENV] = [f"{key}={value}" for key, value in env.items()]
118 models[launch.model_id] = entry
119 config: dict[str, object] = {
120 _KEY_HEALTH_TIMEOUT: _health_check_timeout_s(launches),
121 _KEY_LOG_LEVEL: _LOG_LEVEL,
122 _KEY_MODELS: models,
123 _KEY_GROUPS: {
124 _GROUP_NAME: {
125 _KEY_SWAP: swap,
126 _KEY_EXCLUSIVE: False,
127 _KEY_PERSISTENT: True,
128 _KEY_MEMBERS: [launch.model_id for launch in launches],
129 }
130 },
131 }
132 return json.dumps(config, indent=2)
135def _concurrency_limit(launch: InstanceLaunch) -> int:
136 """Requests llama-swap lets in flight for *launch* before it answers 429."""
137 return _CONCURRENCY_PROCESSES * max(launch.slots, max_workers())
140def cold_load_timeout_s(weights_bytes: int, role: WorkerRole) -> int:
141 """Cold-load ceiling for one member's weights at a conservative disk rate, floored per role.
143 The single source of the scaling formula: llama-swap's health-check timeout
144 and the provider's per-client request timeout both derive from it, so a model
145 whose load llama-swap would wait out can never time out the client first.
146 """
147 return max(_COLD_LOAD_FLOOR_S[role], weights_bytes // _COLD_LOAD_BYTES_PER_S)
150def _health_check_timeout_s(launches: list[InstanceLaunch]) -> int:
151 """Slowest member's cold-load ceiling; the timeout is proxy-global in
152 llama-swap, so the slowest possible load sets it."""
153 return max(
154 (cold_load_timeout_s(launch.weights_bytes, launch.role) for launch in launches),
155 default=max(_COLD_LOAD_FLOOR_S.values()),
156 )
159def _command_line(argv: list[str], port: int) -> str:
160 """Shell command for a member: the role argv plus its explicit port.
162 Quoting must match how llama-swap splits the command back into argv: MS
163 rules on Windows (POSIX single quotes would stay literal in the paths and
164 the spawn fails with "file does not exist"), POSIX everywhere else.
165 """
166 if sys.platform == "win32":
167 rendered = subprocess.list2cmdline(argv)
168 else:
169 rendered = shlex.join(argv)
170 return f"{rendered} {PORT_FLAG} {port}"