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.
Files changed (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. 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