mjolnir-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mjolnir_cli/__init__.py +8 -0
- mjolnir_cli/__main__.py +4 -0
- mjolnir_cli/accuracy.py +333 -0
- mjolnir_cli/autoresearch.py +539 -0
- mjolnir_cli/benchy.py +475 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
- mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
- mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
- mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
- mjolnir_cli/bundled/docker/perses/README.md +89 -0
- mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
- mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
- mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
- mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
- mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
- mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
- mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
- mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
- mjolnir_cli/cachectl.py +146 -0
- mjolnir_cli/charts.py +1687 -0
- mjolnir_cli/cli.py +1480 -0
- mjolnir_cli/clidoc.py +170 -0
- mjolnir_cli/config.py +346 -0
- mjolnir_cli/dockerctl.py +533 -0
- mjolnir_cli/drivers/fa2_driver.py +89 -0
- mjolnir_cli/drivers/fi_driver.py +134 -0
- mjolnir_cli/e2e.py +302 -0
- mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
- mjolnir_cli/fonts/OFL.txt +92 -0
- mjolnir_cli/gate.py +233 -0
- mjolnir_cli/history.py +159 -0
- mjolnir_cli/interactive.py +383 -0
- mjolnir_cli/legs.py +109 -0
- mjolnir_cli/litellm.py +254 -0
- mjolnir_cli/perses.py +214 -0
- mjolnir_cli/picker.py +70 -0
- mjolnir_cli/readme.py +1020 -0
- mjolnir_cli/stack.py +340 -0
- mjolnir_cli/tasks.py +122 -0
- mjolnir_cli/theme.py +281 -0
- mjolnir_cli/thor.py +108 -0
- mjolnir_cli/verify/__init__.py +2 -0
- mjolnir_cli/verify/cli.py +816 -0
- mjolnir_cli/verify/docker.py +334 -0
- mjolnir_cli/verify/kvcache.py +180 -0
- mjolnir_cli/verify/legs.py +134 -0
- mjolnir_cli/verify/ncu.py +141 -0
- mjolnir_cli/verify/profile.py +312 -0
- mjolnir_cli/verify/report.py +177 -0
- mjolnir_cli/verify/results.py +157 -0
- mjolnir_cli/verify/spec.py +279 -0
- mjolnir_cli/verify/stages.py +817 -0
- mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
- mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
- mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
- mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
- mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
mjolnir_cli/litellm.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""LiteLLM proxy stack: ``litellm`` + ``postgres`` + ``redis``.
|
|
2
|
+
|
|
3
|
+
Independent of the vLLM server — ``mjolnir litellm down`` leaves the vLLM
|
|
4
|
+
running, and ``mjolnir serve down`` leaves the proxy up. The proxy reaches
|
|
5
|
+
the vLLM through ``host.docker.internal`` (the host-published vLLM port), so
|
|
6
|
+
the two never share a docker network and neither knows the other's
|
|
7
|
+
container name.
|
|
8
|
+
|
|
9
|
+
The config is rendered on every ``mjolnir litellm up`` from the user's
|
|
10
|
+
template (``$MJOLNIR_LITELLM_TEMPLATE`` or, by default, inside the litellm
|
|
11
|
+
data dir: ``~/.local/share/mjolnir/litellm/litellm_config.template.yaml``)
|
|
12
|
+
with the *currently selected* model/config (``mjolnir model``):
|
|
13
|
+
``${MAIN_LLM_MODEL}`` = the served name, ``${MAIN_LLM_MODEL_NAME}`` = the raw
|
|
14
|
+
``model:`` field, ``${RAW_MODEL_SUFFIXED}`` = ``<model>-<quant>``.
|
|
15
|
+
Everything else resolves from the user's env file (``$MJOLNIR_LITELLM_ENV``,
|
|
16
|
+
default ``~/.local/share/mjolnir/litellm/litellm.env``).
|
|
17
|
+
|
|
18
|
+
ALL user-persisted litellm files live in one dir — ``$MJOLNIR_DATA/litellm``
|
|
19
|
+
(default ``~/.local/share/mjolnir/litellm``): the template, the env file,
|
|
20
|
+
the rendered config, and the stack data (postgres + redis).
|
|
21
|
+
|
|
22
|
+
The compose machinery (env file, data dir, up/down/status/logs, rollback,
|
|
23
|
+
health-wait) lives in :mod:`mjolnir.stack`; this module is the stack's glue:
|
|
24
|
+
the spec, the template rendering, the model-variable injection.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from mjolnir_cli import stack
|
|
35
|
+
from mjolnir_cli.config import Settings, config_field
|
|
36
|
+
|
|
37
|
+
SPEC = stack.StackSpec(
|
|
38
|
+
name="litellm",
|
|
39
|
+
compose_rel=Path("docker/litellm/docker-compose.yaml"),
|
|
40
|
+
env_file_var="MJOLNIR_LITELLM_ENV",
|
|
41
|
+
data_dir_var="MJOLNIR_LITELLM_DATA",
|
|
42
|
+
data_dir_env="LITELLM_DATA_DIR",
|
|
43
|
+
data_subdirs=("db", "redis"),
|
|
44
|
+
port_env="LITELLM_PORT",
|
|
45
|
+
default_port=8000,
|
|
46
|
+
wait_timeout_s=600.0,
|
|
47
|
+
)
|
|
48
|
+
DEFAULT_PROXY_PORT = SPEC.default_port
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LitellmError(stack.StackError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── paths ──────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def template_path() -> Path:
|
|
59
|
+
"""The user's template: ``$MJOLNIR_LITELLM_TEMPLATE`` or, by default,
|
|
60
|
+
in the litellm data dir:
|
|
61
|
+
``~/.local/share/mjolnir/litellm/litellm_config.template.yaml``."""
|
|
62
|
+
env = os.environ.get("MJOLNIR_LITELLM_TEMPLATE")
|
|
63
|
+
if env:
|
|
64
|
+
return Path(env).expanduser()
|
|
65
|
+
return data_dir() / "litellm_config.template.yaml"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def env_file_path() -> Path:
|
|
69
|
+
"""The user's env file (optional): ``$MJOLNIR_LITELLM_ENV`` or, by
|
|
70
|
+
default, in the litellm data dir:
|
|
71
|
+
``~/.local/share/mjolnir/litellm/litellm.env``."""
|
|
72
|
+
return SPEC.env_file_path()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def data_dir() -> Path:
|
|
76
|
+
"""Stack data root: ``$MJOLNIR_LITELLM_DATA`` or
|
|
77
|
+
``$MJOLNIR_DATA/litellm`` (default ``~/.local/share/mjolnir/litellm``);
|
|
78
|
+
holds the template/env/rendered config + the postgres/redis state
|
|
79
|
+
dirs."""
|
|
80
|
+
return SPEC.data_dir()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compose_file() -> Path:
|
|
84
|
+
return SPEC.compose_file()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── env loading + rendering ────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _model_field(s: Settings) -> str:
|
|
91
|
+
"""The raw ``model:`` field of the config yaml (falls back to the model
|
|
92
|
+
path) — the ``MAIN_LLM_MODEL_NAME`` the template's alias table wants."""
|
|
93
|
+
return config_field(s.config_path, "model") or s.model
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def injected_env(s: Settings, override_base_url: str | None = None) -> dict[str, str]:
|
|
97
|
+
"""The variables mjolnir injects from the active model/config."""
|
|
98
|
+
name = _model_field(s)
|
|
99
|
+
return {
|
|
100
|
+
"MAIN_LLM_MODEL": s.served_model,
|
|
101
|
+
"MAIN_LLM_MODEL_NAME": name,
|
|
102
|
+
"RAW_MODEL_SUFFIXED": f"{name}-{s.quant}",
|
|
103
|
+
"LITELLM_MAIN_MODEL_BASE_URL": override_base_url or f"http://host.docker.internal:{s.port}/v1",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolved_env(s: Settings, proxy_port: int | None = None) -> dict[str, str]:
|
|
108
|
+
"""Merged env for rendering + compose interpolation:
|
|
109
|
+
user env file < injected model variables (the CLI selection wins) <
|
|
110
|
+
stack defaults (data dir, proxy port)."""
|
|
111
|
+
env = stack.load_env_file(env_file_path())
|
|
112
|
+
extra = injected_env(s, override_base_url=env.get("LITELLM_MAIN_MODEL_BASE_URL"))
|
|
113
|
+
# CLI flag > env file > default
|
|
114
|
+
if proxy_port:
|
|
115
|
+
extra["LITELLM_PORT"] = str(proxy_port)
|
|
116
|
+
return stack.resolve_env(SPEC, extra)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate(rendered: str) -> list[str]:
|
|
120
|
+
"""Problems with a rendered config (empty list = ok)."""
|
|
121
|
+
problems: list[str] = []
|
|
122
|
+
try:
|
|
123
|
+
doc = yaml.safe_load(rendered)
|
|
124
|
+
except yaml.YAMLError as e:
|
|
125
|
+
return [f"rendered config is not valid YAML: {e}".replace("\n", " ")]
|
|
126
|
+
ml = doc.get("model_list") if isinstance(doc, dict) else None
|
|
127
|
+
if not isinstance(ml, list):
|
|
128
|
+
problems.append("no model_list in the rendered config")
|
|
129
|
+
else:
|
|
130
|
+
for i, e in enumerate(ml):
|
|
131
|
+
name = (e or {}).get("model_name")
|
|
132
|
+
if not name or not str(name).strip():
|
|
133
|
+
problems.append(f"model_list[{i}] has an empty model_name — check the template variables")
|
|
134
|
+
return problems
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_config(s: Settings, proxy_port: int | None = None) -> Path:
|
|
138
|
+
"""Render the template → ``data_dir()/litellm_config.yaml``; raises
|
|
139
|
+
``LitellmError`` on a missing template or unresolved variables."""
|
|
140
|
+
tpl = template_path()
|
|
141
|
+
if not tpl.exists():
|
|
142
|
+
example = compose_file().parent / "litellm_config.template.example.yaml"
|
|
143
|
+
msg = (
|
|
144
|
+
f"template not found: {tpl}\n"
|
|
145
|
+
" start from the example (or override the location with "
|
|
146
|
+
"$MJOLNIR_LITELLM_TEMPLATE):\n"
|
|
147
|
+
f" cp {example} {tpl}"
|
|
148
|
+
)
|
|
149
|
+
legacy = Path.home() / "litellm_config.template.yaml"
|
|
150
|
+
if legacy.exists() and legacy != tpl:
|
|
151
|
+
msg += f"\n found a legacy copy — move it: mv {legacy} {tpl}"
|
|
152
|
+
raise LitellmError(msg)
|
|
153
|
+
env = resolved_env(s, proxy_port)
|
|
154
|
+
rendered, missing = stack.render(tpl.read_text(), env)
|
|
155
|
+
if missing:
|
|
156
|
+
src = env_file_path()
|
|
157
|
+
raise LitellmError(
|
|
158
|
+
"unresolved template variable(s): "
|
|
159
|
+
+ ", ".join("${" + m + "}" for m in missing)
|
|
160
|
+
+ f"\n define them in {src} (see the examples in "
|
|
161
|
+
f"{compose_file().parent})"
|
|
162
|
+
)
|
|
163
|
+
problems = validate(rendered)
|
|
164
|
+
if problems:
|
|
165
|
+
raise LitellmError("rendered config invalid: " + "; ".join(problems))
|
|
166
|
+
out = data_dir() / "litellm_config.yaml"
|
|
167
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
out.write_text(rendered)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── stack glue (the compose driver is mjolnir.stack) ───────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _port_of(env: dict[str]) -> int:
|
|
176
|
+
try:
|
|
177
|
+
return int(env.get("LITELLM_PORT", DEFAULT_PROXY_PORT))
|
|
178
|
+
except ValueError:
|
|
179
|
+
return DEFAULT_PROXY_PORT
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def up(
|
|
183
|
+
s: Settings, proxy_port: int | None = None, wait: bool = True, wait_timeout_s: float = 600.0, dry_run: bool = False
|
|
184
|
+
) -> int:
|
|
185
|
+
"""Render the config and start the stack (proxy + postgres + redis)."""
|
|
186
|
+
cfg_path: Path | None = None
|
|
187
|
+
|
|
188
|
+
def prepare(s2: Settings) -> dict[str, str]:
|
|
189
|
+
nonlocal cfg_path
|
|
190
|
+
cfg_path = render_config(s2, proxy_port)
|
|
191
|
+
if dry_run:
|
|
192
|
+
print(f"(rendered config: {cfg_path})")
|
|
193
|
+
return {}
|
|
194
|
+
|
|
195
|
+
def health(env: dict[str]) -> str:
|
|
196
|
+
return f"http://127.0.0.1:{_port_of(env)}/health/liveliness"
|
|
197
|
+
|
|
198
|
+
def banner(env: dict[str]) -> list[str]:
|
|
199
|
+
p = _port_of(env)
|
|
200
|
+
return [
|
|
201
|
+
f"litellm proxy: http://127.0.0.1:{p} (master key: {env.get('LITELLM_MASTER_KEY', 'sk-1234')})",
|
|
202
|
+
f" UI: http://127.0.0.1:{p}/ui"
|
|
203
|
+
f" ({env.get('LITELLM_UI_USERNAME', 'admin')}/"
|
|
204
|
+
f"{env.get('LITELLM_UI_PASSWORD', 'admin')})",
|
|
205
|
+
f" vllm main model: {env['MAIN_LLM_MODEL']} @ {env['LITELLM_MAIN_MODEL_BASE_URL']}",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
stack.up(
|
|
210
|
+
SPEC,
|
|
211
|
+
s,
|
|
212
|
+
prepare=prepare,
|
|
213
|
+
wait=wait,
|
|
214
|
+
wait_timeout_s=wait_timeout_s,
|
|
215
|
+
dry_run=dry_run,
|
|
216
|
+
health=health,
|
|
217
|
+
banner=banner,
|
|
218
|
+
compose_wait=True,
|
|
219
|
+
)
|
|
220
|
+
except stack.StackError as e:
|
|
221
|
+
env = resolved_env(s, proxy_port)
|
|
222
|
+
hint = (
|
|
223
|
+
f"\n is host port {_port_of(env)} already taken? try: mjolnir litellm up --port <other>"
|
|
224
|
+
if "port is already allocated" in str(e)
|
|
225
|
+
else ""
|
|
226
|
+
)
|
|
227
|
+
raise LitellmError(str(e) + hint)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def down(purge: bool = False, dry_run: bool = False) -> int:
|
|
232
|
+
"""Stop the stack (data kept; ``purge`` wipes postgres + redis data)."""
|
|
233
|
+
return stack.down(SPEC, purge=purge, dry_run=dry_run, keep_note="postgres/redis state survives")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def status(s: Settings, proxy_port: int | None = None) -> dict:
|
|
237
|
+
env = resolved_env(s, proxy_port)
|
|
238
|
+
port = _port_of(env)
|
|
239
|
+
url = f"http://127.0.0.1:{port}"
|
|
240
|
+
return stack.status(
|
|
241
|
+
SPEC,
|
|
242
|
+
extra={
|
|
243
|
+
"proxy": {
|
|
244
|
+
"url": url,
|
|
245
|
+
"liveliness": stack.probe(url + "/health/liveliness"),
|
|
246
|
+
"config": str(data_dir() / "litellm_config.yaml"),
|
|
247
|
+
"template": str(template_path()),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def logs(follow: bool = False, tail: int = 100) -> int:
|
|
254
|
+
return stack.logs(SPEC, follow=follow, tail=tail)
|
mjolnir_cli/perses.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Perses + Prometheus observability stack for the vLLM server.
|
|
2
|
+
|
|
3
|
+
Same contract as litellm: **independent** of the vLLM server — ``perses
|
|
4
|
+
down`` leaves the server running, and ``serve down`` leaves the stack up.
|
|
5
|
+
Prometheus scrapes the server's /metrics through
|
|
6
|
+
``host.docker.internal:<port>`` (the resolved port, same as
|
|
7
|
+
``mjolnir serve``), so the stack works whether the server is up or down.
|
|
8
|
+
The two vLLM dashboards (vendored from vllm-project/vllm,
|
|
9
|
+
``docker/perses/dashboards/``) are PROVISIONED into Perses from a read-only
|
|
10
|
+
folder — no manual import.
|
|
11
|
+
|
|
12
|
+
All user-persisted files live in one dir — ``$MJOLNIR_PERSER_DATA`` or
|
|
13
|
+
``$MJOLNIR_DATA/perses`` (default ``~/.local/share/mjolnir/perses``): the
|
|
14
|
+
env file, the rendered prometheus.yml, and the stack data (``db/`` = Perses
|
|
15
|
+
file DB, ``prom/`` = Prometheus TSDB).
|
|
16
|
+
|
|
17
|
+
The compose machinery (env file, data dir, up/down/status/logs, rollback,
|
|
18
|
+
health-wait) lives in :mod:`mjolnir.stack`; this module is the stack's glue:
|
|
19
|
+
the spec, the prometheus.yml rendering, the health URL, the banner.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
from mjolnir_cli import stack
|
|
29
|
+
from mjolnir_cli.config import Settings, resource_path
|
|
30
|
+
|
|
31
|
+
SPEC = stack.StackSpec(
|
|
32
|
+
name="perses",
|
|
33
|
+
compose_rel=Path("docker/perses/docker-compose.yaml"),
|
|
34
|
+
env_file_var="MJOLNIR_PERSER_ENV",
|
|
35
|
+
data_dir_var="MJOLNIR_PERSER_DATA",
|
|
36
|
+
data_dir_env="PERSER_DATA_DIR",
|
|
37
|
+
data_subdirs=("db", "prom"),
|
|
38
|
+
port_env="PERSER_PORT",
|
|
39
|
+
default_port=8080,
|
|
40
|
+
wait_timeout_s=300.0,
|
|
41
|
+
)
|
|
42
|
+
DEFAULT_PORT = SPEC.default_port
|
|
43
|
+
PROMETHEUS_DEFAULT_PORT = 9090
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PersesError(stack.StackError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── prometheus.yml rendering ───────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def vllm_metrics_target(s: Settings, override: str | None = None) -> str:
|
|
54
|
+
"""The Prometheus scrape target for the VLLM server as ``host:port``
|
|
55
|
+
(prometheus target form — the path comes from ``metrics_path``): the
|
|
56
|
+
resolved port on host.docker.internal (the host-published port the
|
|
57
|
+
vLLM container listens on), or the ``PERSER_VLLM_TARGET`` env-file
|
|
58
|
+
override."""
|
|
59
|
+
return override or f"host.docker.internal:{s.port}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_prometheus(s: Settings, vllm_target: str | None = None) -> Path:
|
|
63
|
+
"""Render ``prometheus.template.yaml`` → ``data_dir()/prometheus.yml``
|
|
64
|
+
with the vLLM scrape target of the active config."""
|
|
65
|
+
tpl = resource_path(SPEC.compose_rel.parent / "prometheus.template.yaml")
|
|
66
|
+
if not tpl.exists():
|
|
67
|
+
raise PersesError(f"prometheus template not found: {tpl}")
|
|
68
|
+
rendered, missing = stack.render(tpl.read_text(), {"VLLM_METRICS_TARGET": vllm_metrics_target(s, vllm_target)})
|
|
69
|
+
if missing:
|
|
70
|
+
raise PersesError(f"unresolved variable(s) in the prometheus template: {', '.join(missing)}")
|
|
71
|
+
try:
|
|
72
|
+
yaml.safe_load(rendered)
|
|
73
|
+
except yaml.YAMLError as e:
|
|
74
|
+
raise PersesError(f"rendered prometheus config invalid: {e}".replace("\n", " "))
|
|
75
|
+
out = SPEC.data_dir() / "prometheus.yml"
|
|
76
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
out.write_text(rendered)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── stack glue (the compose driver is mjolnir.stack) ───────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _port_of(env: dict[str]) -> int:
|
|
85
|
+
try:
|
|
86
|
+
return int(env.get("PERSER_PORT", DEFAULT_PORT))
|
|
87
|
+
except ValueError:
|
|
88
|
+
return DEFAULT_PORT
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _prom_port_of(env: dict[str]) -> int:
|
|
92
|
+
try:
|
|
93
|
+
return int(env.get("PROMETHEUS_PORT", PROMETHEUS_DEFAULT_PORT))
|
|
94
|
+
except ValueError:
|
|
95
|
+
return PROMETHEUS_DEFAULT_PORT
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def up(
|
|
99
|
+
s: Settings, port: int | None = None, wait: bool = True, wait_timeout_s: float = 300.0, dry_run: bool = False
|
|
100
|
+
) -> int:
|
|
101
|
+
"""Start the stack (prometheus + perses) with the vLLM dashboards
|
|
102
|
+
provisioned. The vLLM scrape target = the active config's port (the
|
|
103
|
+
server may be up or down)."""
|
|
104
|
+
file_env = stack.load_env_file(SPEC.env_file_path())
|
|
105
|
+
target = vllm_metrics_target(s, file_env.get("PERSER_VLLM_TARGET"))
|
|
106
|
+
extra: dict[str, str] = {}
|
|
107
|
+
if port:
|
|
108
|
+
extra["PERSER_PORT"] = str(port)
|
|
109
|
+
|
|
110
|
+
cfg_path: Path | None = None
|
|
111
|
+
|
|
112
|
+
def prepare(s2: Settings) -> dict[str, str]:
|
|
113
|
+
nonlocal cfg_path
|
|
114
|
+
for sub in SPEC.data_subdirs:
|
|
115
|
+
d = SPEC.data_dir() / sub
|
|
116
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
# the containers run nonroot (perses uid 65532, prometheus
|
|
118
|
+
# 65534) and need to write their state into these host dirs
|
|
119
|
+
d.chmod(0o777)
|
|
120
|
+
cfg_path = render_prometheus(s2, file_env.get("PERSER_VLLM_TARGET"))
|
|
121
|
+
if dry_run:
|
|
122
|
+
print(f"(rendered prometheus config: {cfg_path})")
|
|
123
|
+
return extra
|
|
124
|
+
|
|
125
|
+
def health(env: dict[str]) -> str:
|
|
126
|
+
return f"http://127.0.0.1:{_port_of(env)}/api/v1/projects"
|
|
127
|
+
|
|
128
|
+
def banner(env: dict[str]) -> list[str]:
|
|
129
|
+
p, pp = _port_of(env), _prom_port_of(env)
|
|
130
|
+
return [
|
|
131
|
+
f"perses: http://127.0.0.1:{p} (dashboards: Performance Statistics, Query Statistics)",
|
|
132
|
+
f"prometheus: http://127.0.0.1:{pp} (scrapes {target}/metrics — targets: http://127.0.0.1:{pp}/targets)",
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
stack.up(
|
|
137
|
+
SPEC,
|
|
138
|
+
s,
|
|
139
|
+
prepare=prepare,
|
|
140
|
+
wait=wait,
|
|
141
|
+
wait_timeout_s=wait_timeout_s,
|
|
142
|
+
dry_run=dry_run,
|
|
143
|
+
health=health,
|
|
144
|
+
banner=banner,
|
|
145
|
+
)
|
|
146
|
+
except stack.StackError as e:
|
|
147
|
+
raise PersesError(str(e))
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def down(purge: bool = False, dry_run: bool = False) -> int:
|
|
152
|
+
"""Stop the stack (the vLLM server keeps running; data kept by default,
|
|
153
|
+
``purge`` wipes the perses db + prometheus TSDB)."""
|
|
154
|
+
return stack.down(SPEC, purge=purge, dry_run=dry_run, keep_note="prometheus TSDB + perses db survive")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _rendered_target() -> str | None:
|
|
158
|
+
"""The scrape target the rendered ``prometheus.yml`` actually points at
|
|
159
|
+
(what the *running* stack is scraping) — the source of truth, which may
|
|
160
|
+
differ from the settings-resolved port (e.g. after an ``up`` with a
|
|
161
|
+
different port). None if not yet rendered / unreadable."""
|
|
162
|
+
yml = SPEC.data_dir() / "prometheus.yml"
|
|
163
|
+
if not yml.exists():
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
data = yaml.safe_load(yml.read_text())
|
|
167
|
+
except yaml.YAMLError:
|
|
168
|
+
return None
|
|
169
|
+
for job in (data or {}).get("scrape_configs", []):
|
|
170
|
+
for sc in job.get("static_configs", []):
|
|
171
|
+
targets = sc.get("targets") or []
|
|
172
|
+
if targets:
|
|
173
|
+
return targets[0]
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def status(s: Settings, port: int | None = None) -> dict:
|
|
178
|
+
env = stack.resolve_env(SPEC, {"PERSER_PORT": str(port)} if port else None)
|
|
179
|
+
p, pp = _port_of(env), _prom_port_of(env)
|
|
180
|
+
target = _rendered_target() or vllm_metrics_target(
|
|
181
|
+
s, stack.load_env_file(SPEC.env_file_path()).get("PERSER_VLLM_TARGET")
|
|
182
|
+
)
|
|
183
|
+
services = stack.project_services(SPEC)
|
|
184
|
+
live = any(v.get("state") == "running" for v in services.values())
|
|
185
|
+
return stack.status(
|
|
186
|
+
SPEC,
|
|
187
|
+
extra={
|
|
188
|
+
"perses": {
|
|
189
|
+
"url": f"http://127.0.0.1:{p}",
|
|
190
|
+
"api": stack.probe(f"http://127.0.0.1:{p}/api/v1/projects") if live else "down",
|
|
191
|
+
},
|
|
192
|
+
"prometheus": {
|
|
193
|
+
"url": f"http://127.0.0.1:{pp}",
|
|
194
|
+
"health": stack.probe(f"http://127.0.0.1:{pp}/-/healthy") if live else "down",
|
|
195
|
+
},
|
|
196
|
+
"vllm_target": f"http://{target}/metrics",
|
|
197
|
+
"data_dir": str(SPEC.data_dir()),
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def logs(follow: bool = False, tail: int = 100, service: str | None = None) -> int:
|
|
203
|
+
"""Tail a stack service's logs (default ``perses``; ``--service
|
|
204
|
+
prometheus`` for the scraper)."""
|
|
205
|
+
return stack.logs(SPEC, follow=follow, tail=tail, service=service)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def running_port() -> int | None:
|
|
209
|
+
"""The Perses UI port if the stack is up (the ``serve status`` hint),
|
|
210
|
+
else None."""
|
|
211
|
+
svc = stack.project_services(SPEC).get(SPEC.name) or {}
|
|
212
|
+
if svc.get("state") != "running":
|
|
213
|
+
return None
|
|
214
|
+
return _port_of(stack.resolve_env(SPEC))
|
mjolnir_cli/picker.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""A minimal arrow-key selection menu on ``curses`` (stdlib — no new deps).
|
|
2
|
+
|
|
3
|
+
Used by ``mjolnir model`` / ``mjolnir image``: pick the active model
|
|
4
|
+
config or the vLLM image with the arrow keys and Enter. Esc/q cancels.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import curses
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Cancelled(Exception):
|
|
14
|
+
"""The user cancelled the picker (Esc / q), or the terminal could not
|
|
15
|
+
run the curses UI."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_FOOTER = "↑/↓ move · enter select · esc cancel"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(stdscr, choices: list[str], start: int, title: str) -> str:
|
|
22
|
+
curses.curs_set(0)
|
|
23
|
+
cur = start
|
|
24
|
+
while True:
|
|
25
|
+
stdscr.erase()
|
|
26
|
+
cols = curses.COLS
|
|
27
|
+
lines = curses.LINES
|
|
28
|
+
stdscr.addstr(0, 0, title[: cols - 1])
|
|
29
|
+
room = max(lines - 3, 1) # title row + footer row
|
|
30
|
+
top = max(0, min(cur - room + 1, len(choices) - room))
|
|
31
|
+
for i in range(top, min(top + room, len(choices))):
|
|
32
|
+
y = i - top + 1
|
|
33
|
+
line = " " + choices[i]
|
|
34
|
+
if i == cur:
|
|
35
|
+
stdscr.addstr(y, 0, line[: cols - 1], curses.A_REVERSE)
|
|
36
|
+
else:
|
|
37
|
+
stdscr.addstr(y, 0, line[: cols - 1])
|
|
38
|
+
stdscr.addstr(lines - 1, 0, _FOOTER[: cols - 1])
|
|
39
|
+
stdscr.refresh()
|
|
40
|
+
|
|
41
|
+
key = stdscr.getch()
|
|
42
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
43
|
+
cur = max(0, cur - 1)
|
|
44
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
45
|
+
cur = min(len(choices) - 1, cur + 1)
|
|
46
|
+
elif key == curses.KEY_HOME:
|
|
47
|
+
cur = 0
|
|
48
|
+
elif key == curses.KEY_END:
|
|
49
|
+
cur = len(choices) - 1
|
|
50
|
+
elif key in (curses.KEY_ENTER, 10, 13, ord(" ")):
|
|
51
|
+
return choices[cur]
|
|
52
|
+
elif key in (27, ord("q")):
|
|
53
|
+
raise Cancelled
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def select(title: str, choices: list[str], default: str | None = None) -> str:
|
|
57
|
+
"""Arrow-key menu over ``choices``; returns the chosen item.
|
|
58
|
+
|
|
59
|
+
Raises ``Cancelled`` on Esc/q or when the terminal can't run curses
|
|
60
|
+
(non-tty, TERM=dumb). Callers should check ``isatty`` first and offer
|
|
61
|
+
the explicit-argument path instead."""
|
|
62
|
+
if not choices:
|
|
63
|
+
raise ValueError("select() needs at least one choice")
|
|
64
|
+
start = choices.index(default) if default in choices else 0
|
|
65
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
66
|
+
raise Cancelled
|
|
67
|
+
try:
|
|
68
|
+
return curses.wrapper(_run, choices, start, title)
|
|
69
|
+
except (curses.error, OSError) as e:
|
|
70
|
+
raise Cancelled from e
|