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/clidoc.py ADDED
@@ -0,0 +1,170 @@
1
+ """The CLI reference, generated from the typer commands themselves.
2
+
3
+ ``mjolnir plot --readme`` writes ``docs/cli.md`` (every command and sub-command
4
+ with its description, arguments and options, defaults included), so the docs
5
+ can never drift from the code: change a command's docstring or an option's
6
+ help and the next render carries it — CI fails if the committed copy is stale.
7
+ The README keeps a short hand-written overview; :func:`block_commands` lets a
8
+ test check that every command it names exists.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ try: # typer >= 0.20 vendors its own click
18
+ from typer._click import Context
19
+ except ImportError: # pragma: no cover
20
+ from click import Context
21
+
22
+
23
+ @dataclass
24
+ class Cmd:
25
+ path: str # "mjolnir serve up"
26
+ short: str # first line of the help
27
+ help: str # the whole help text
28
+ params: list[Any]
29
+ group: bool
30
+
31
+
32
+ def _clean(text: str | None) -> str:
33
+ import inspect
34
+
35
+ return inspect.cleandoc(text or "").replace("\b\n", "").strip()
36
+
37
+
38
+ def _rst(text: str) -> str:
39
+ """``x`` -> `x`, and <placeholders> outside code spans escaped (GitHub would
40
+ swallow them as HTML tags)."""
41
+ text = re.sub(r"``([^`]+)``", r"`\1`", text)
42
+ parts = re.split(r"(`[^`]*`)", text)
43
+ return "".join(p if p.startswith("`") else p.replace("<", "&lt;").replace(">", "&gt;") for p in parts)
44
+
45
+
46
+ # options many commands share, documented once here (their typer help is empty)
47
+ COMMON_HELP = {
48
+ "--model": "the model (`vendor/Model`; default: the active one, `mjolnir model`)",
49
+ "--config": "the model config (default: the active one)",
50
+ "--image": "the vLLM image (default: the active one, `mjolnir image`)",
51
+ "--port": "the server port (default: the active one)",
52
+ "--dry-run": "print what would run, run nothing",
53
+ "--gate": "wait for a clean window (vLLM queue 0/0) before measuring",
54
+ "--plot": "re-render the charts / README afterwards",
55
+ "--timeout": "seconds before a step is given up",
56
+ }
57
+
58
+
59
+ def _short(cmd: Any) -> str:
60
+ """The first paragraph of the help, on one line."""
61
+ text = _clean(cmd.short_help or cmd.help)
62
+ return _rst(text.split("\n\n", 1)[0].replace("\n", " ").strip())
63
+
64
+
65
+ def walk(app) -> list[Cmd]:
66
+ """Every command, depth first, in the order the CLI lists them."""
67
+ import typer.main
68
+
69
+ root = typer.main.get_command(app)
70
+ out: list[Cmd] = []
71
+
72
+ def visit(cmd: Any, path: str, ctx: Any) -> None:
73
+ if getattr(cmd, "hidden", False):
74
+ return
75
+ group = hasattr(cmd, "list_commands")
76
+ out.append(Cmd(path, _short(cmd), _rst(_clean(cmd.help)), list(cmd.params), group))
77
+ if group:
78
+ for name in cmd.list_commands(ctx):
79
+ sub = cmd.get_command(ctx, name)
80
+ if sub is not None:
81
+ visit(sub, f"{path} {name}", Context(sub, info_name=name, parent=ctx))
82
+
83
+ visit(root, "mjolnir", Context(root, info_name="mjolnir"))
84
+ return out
85
+
86
+
87
+ def _is_option(p: Any) -> bool:
88
+ return getattr(p, "param_type_name", "") == "option"
89
+
90
+
91
+ def _default(p: Any) -> str:
92
+ d = p.default
93
+ if callable(d) or d is None or d == () or d == []:
94
+ return ""
95
+ if _is_option(p) and getattr(p, "is_flag", False) and p.secondary_opts:
96
+ return p.opts[0] if d else p.secondary_opts[0]
97
+ if isinstance(d, bool):
98
+ return "on" if d else "off"
99
+ return f"`{d}`"
100
+
101
+
102
+ def _param_row(p: Any) -> list[str] | None:
103
+ if _is_option(p):
104
+ if p.hidden or p.name in ("help",):
105
+ return None
106
+ names = " / ".join(f"`{o}`" for o in [*p.opts, *p.secondary_opts])
107
+ help_ = _rst(p.help or COMMON_HELP.get(p.opts[0], "")).replace("\n", " ").replace("|", "\\|")
108
+ else:
109
+ names = f"`{(p.human_readable_name or p.name or '').upper()}`"
110
+ help_ = "argument"
111
+ return [names, help_, _default(p)]
112
+
113
+
114
+ def reference(app) -> str:
115
+ """docs/cli.md."""
116
+ cmds = walk(app)
117
+ lines = [
118
+ "# The `mjolnir` CLI reference",
119
+ "",
120
+ "> Generated from the commands themselves by `mjolnir plot --readme` "
121
+ "(`mjolnir_cli/clidoc.py`) — do not edit by hand. `mjolnir <command> --help` prints the same.",
122
+ "",
123
+ "| command | what it does |",
124
+ "|---|---|",
125
+ ]
126
+ for c in cmds[1:]:
127
+ anchor = c.path.replace(" ", "-")
128
+ lines.append(f"| [`{c.path}`](#{anchor}) | {c.short.replace('|', chr(92) + '|')} |")
129
+ for c in cmds[1:]:
130
+ lines += ["", f'<a id="{c.path.replace(" ", "-")}"></a>', "", f"## `{c.path}`", ""]
131
+ if c.help:
132
+ lines += [c.help, ""]
133
+ rows = [r for r in (_param_row(p) for p in c.params) if r]
134
+ if rows:
135
+ lines += ["| option | description | default |", "|---|---|---|"]
136
+ lines += ["| " + " | ".join(r) + " |" for r in rows]
137
+ lines.append("")
138
+ if c.group:
139
+ lines.append("Sub-commands: " + ", ".join(f"[`{s.path}`](#{s.path.replace(' ', '-')})"
140
+ for s in cmds if s.path.rsplit(" ", 1)[0] == c.path) + ".") # fmt: skip
141
+ lines.append("")
142
+ return "\n".join(lines).rstrip() + "\n"
143
+
144
+
145
+ def block_commands(block: str) -> list[str]:
146
+ """Command paths named in a README-style overview block, one per line:
147
+ ``mjolnir serve [up|down] # …`` -> ``mjolnir serve up``, ``mjolnir serve
148
+ down``; ``mjolnir bench perf | ab`` -> ``mjolnir bench perf``, ``mjolnir
149
+ bench ab``. Options (``--x``, ``[--x]``), placeholders (``<stage>``) and
150
+ comments are ignored."""
151
+ out: list[str] = []
152
+ for line in block.splitlines():
153
+ line = line.split("#", 1)[0].strip()
154
+ if line != "mjolnir" and not line.startswith("mjolnir "):
155
+ continue
156
+ words = []
157
+ for w in line.split()[1:]:
158
+ if w.startswith(("-", "<", "[-")):
159
+ break
160
+ words.append(w)
161
+ text = " ".join(words)
162
+ sub = re.search(r"\[([^\]]+)\]", text)
163
+ if sub: # mjolnir serve [up|down]: sub-commands of the prefix
164
+ prefix = text[: sub.start()].split()
165
+ out += [" ".join(["mjolnir", *prefix, a]) for a in sub.group(1).split("|")]
166
+ continue
167
+ alts = [a.split() for a in text.split("|")] if text else [[]]
168
+ head = alts[0][:-1] # mjolnir verify bench | all: siblings of the last word
169
+ out += [" ".join(["mjolnir", *head, a[-1]] if a else ["mjolnir"]) for a in alts]
170
+ return list(dict.fromkeys(out))
mjolnir_cli/config.py ADDED
@@ -0,0 +1,346 @@
1
+ """Mjolnir defaults + config resolution.
2
+
3
+ Everything the CLI needs has a baked-in default (Qwen3.8-27B NVFP4 served by
4
+ the Mjolnir image with ``--attention-backend MJOLNIR``); flags on each
5
+ command override them, then the remembered state (``mjolnir model`` /
6
+ ``mjolnir image`` → ``$MJOLNIR_STATE``, default ``~/.mjolnir-state.json``),
7
+ then ``$MJOLNIR_*`` env vars. No ``.env`` file required.
8
+
9
+ The CLI runs from a repo checkout (``uv run mjolnir`` — configs, raws and
10
+ charts are the repo's own) or from a PyPI install (``uv tool install
11
+ mjolnir-cli`` — configs and the side-stack files come bundled in the wheel,
12
+ benchmark results land in ``~/.local/share/mjolnir/benchmarks``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ # ── Model / config ───────────────────────────────────────────────────────────
24
+ DEFAULT_MODEL = "Qwen/Qwen3.8-27B"
25
+ # context depths every end-to-end bench (perf / ab / e2e) measures by default:
26
+ # up to 128K (the ncu kernel stages keep their 256K top: verify ALL_LS)
27
+ E2E_DEPTHS: tuple[int, ...] = (0, 4096, 8192, 16384, 32768, 65536, 131072)
28
+ # the fastest Mjolnir config so far (fp8 lm_head + 64K draft vocab, opt-in via its .env):
29
+ # measured next to the default everywhere (verify spec / kv-cache-size / profile, bench ab / accuracy)
30
+ FAST_QUANT = "NVFP4_MJOLNIR_E2M3_LMH8_VT64K"
31
+ DEFAULT_QUANT = "NVFP4_MJOLNIR_E2M3_LMH8_VT64K" # --attention-backend MJOLNIR (this repo's kernel)
32
+ BASELINE_QUANT = "NVFP4" # FlashInfer — the fastest stock backend on Thor
33
+ FA2_QUANT = "NVFP4_FA2" # FlashAttention 2 — vLLM 0.31.0's sm_110 default (bf16 KV)
34
+ SERVED_MODEL_NAME = "Qwen/Qwen3.8-27B"
35
+
36
+ # ── Image / serving ──────────────────────────────────────────────────────────
37
+ # The image tag follows the upstream vLLM version it overlays (see
38
+ # .github/workflows/docker-image.yml): narandill/mjolnir-vllm:v<vllm>.
39
+ IMAGE_REPO = "narandill/mjolnir-vllm"
40
+ VLLM_VERSION = "0.31.0"
41
+ DEFAULT_IMAGE = f"{IMAGE_REPO}:v{VLLM_VERSION}"
42
+ STOCK_IMAGE = f"vllm/vllm-openai:v{VLLM_VERSION}-ubuntu2404"
43
+ DEFAULT_PORT = 6001 # host port; vLLM metrics live on the same port
44
+ CONTAINER_NAME = "mjolnir-vllm"
45
+
46
+ # Friendly backend labels for the history log / charts, keyed by quant config.
47
+ BACKEND_LABELS = {
48
+ "NVFP4_MJOLNIR": "Mjolnir",
49
+ "NVFP4_MJOLNIR_E2M3": "Mjolnir fp6 KV",
50
+ "NVFP4_MJOLNIR_E2M3_LMH8": "Mjolnir fp6 KV + fp8 lm_head",
51
+ "NVFP4_MJOLNIR_E2M3_VT32K": "Mjolnir fp6 KV + 32K draft vocab",
52
+ "NVFP4_MJOLNIR_E2M3_LMH8_VT32K": "Mjolnir fp6 KV + fp8 lm_head + 32K draft vocab",
53
+ "NVFP4_MJOLNIR_E2M3_LMH8_VT64K": "Mjolnir fp6 KV + fp8 lm_head + 64K draft vocab",
54
+ "NVFP4_MJOLNIR_E2M3_DSPARK": "Mjolnir fp6 KV + DSpark",
55
+ "NVFP4_MJOLNIR_E2M3_DFLASH2": "Mjolnir fp6 KV + DFlash2",
56
+ "NVFP4": "FlashInfer",
57
+ "NVFP4_DSPARK": "FlashInfer + DSpark",
58
+ "NVFP4_DFLASH2": "FlashInfer + DFlash2",
59
+ "NVFP4_FA2": "FlashAttention 2",
60
+ "NVFP4_FA4hd256": "FlashAttention 4 hd256",
61
+ }
62
+
63
+ # The repo marker: a checkout has the image build context.
64
+ _REPO_MARKER = Path("docker") / "vllm-thor" / "Dockerfile"
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ConfigEntry:
69
+ """One model config discovered under ``configs/<vendor>/<model>/<quant>/config.yaml``
70
+ (the repo's / bundled configs, or the user-local dir — see ``source``)."""
71
+
72
+ model: str # "vendor/Model", e.g. "Qwen/Qwen3.8-27B"
73
+ quant: str # config name, e.g. "NVFP4_MJOLNIR"
74
+ path: Path
75
+ source: str = "repo" # "repo" (configs/ or bundled) | "local" (~/.local/share/mjolnir/configs)
76
+
77
+ @property
78
+ def backend(self) -> str:
79
+ return BACKEND_LABELS.get(self.quant, self.quant)
80
+
81
+
82
+ def scan_configs(configs_dir: Path) -> list[ConfigEntry]:
83
+ """Scan ``configs/<vendor>/<model>/<quant>/config.yaml`` into pickable
84
+ entries. Everything config-specific lives in the config dir: the
85
+ ``.env`` sidecar and a drafter's ``kv_scales.json``; the model-level files
86
+ (shared ``kv_scales.json``, ``draft_vocab.json``, chat template) stay
87
+ next to it."""
88
+ out: list[ConfigEntry] = []
89
+ if not configs_dir.is_dir():
90
+ return out
91
+ for p in sorted(configs_dir.glob("*/*/*/config.yaml")):
92
+ out.append(ConfigEntry(model=str(p.parent.parent.relative_to(configs_dir)), quant=p.parent.name, path=p))
93
+ return out
94
+
95
+
96
+ def user_configs_dir() -> Path:
97
+ """The user-local model-config root: ``~/.local/share/mjolnir/configs``
98
+ (under the project data dir ``MJOLNIR_DATA``); override with
99
+ ``$MJOLNIR_CONFIGS_DIR``. Layout: ``<vendor>/<model>/<quant>/config.yaml``."""
100
+ return Path(_env("MJOLNIR_CONFIGS_DIR", str(Path.home() / ".local" / "share" / "mjolnir" / "configs"))).expanduser()
101
+
102
+
103
+ def _env(name: str, default: str) -> str:
104
+ return os.environ.get(name, default)
105
+
106
+
107
+ def _env_int(name: str, default: int) -> int:
108
+ try:
109
+ return int(os.environ.get(name, default))
110
+ except ValueError:
111
+ return default
112
+
113
+
114
+ def config_field(path: Path, key: str) -> str | None:
115
+ """One top-level scalar from a config yaml (None if absent/unreadable)."""
116
+ try:
117
+ data = yaml.safe_load(Path(path).read_text())
118
+ except Exception: # noqa: BLE001
119
+ return None
120
+ if not isinstance(data, dict):
121
+ return None
122
+ v = data.get(key)
123
+ return str(v) if v else None
124
+
125
+
126
+ def config_speculative(path: Path) -> dict | None:
127
+ """The config's speculative decoding as ``{"method": "mtp", "tokens": 3}``
128
+ (None when the config has none or is unreadable)."""
129
+ try:
130
+ data = yaml.safe_load(Path(path).read_text())
131
+ except Exception: # noqa: BLE001
132
+ return None
133
+ spec = data.get("speculative-config") if isinstance(data, dict) else None
134
+ if not isinstance(spec, dict) or not spec.get("num_speculative_tokens"):
135
+ return None
136
+ return {"method": str(spec.get("method", "")), "tokens": int(spec["num_speculative_tokens"])}
137
+
138
+
139
+ def config_drafter_on_mjolnir(path: Path) -> bool:
140
+ """Does the config run an external drafter (``speculative-config.model``:
141
+ DSpark, DFlash, EAGLE, ...) on the Mjolnir backend? Only when it says so in
142
+ ``speculative-config.attention_backend`` (MJOLNIR / CUSTOM): vLLM gives a
143
+ DSpark / DFlash drafter its own backend choice — auto unless set there,
144
+ never the target's. Its layers then need fp6 scales of their own,
145
+ calibrated with the config (``<config>.kv_scales.json``)."""
146
+ try:
147
+ data = yaml.safe_load(Path(path).read_text())
148
+ except Exception: # noqa: BLE001
149
+ return False
150
+ spec = data.get("speculative-config") if isinstance(data, dict) else None
151
+ if not isinstance(spec, dict) or not spec.get("model"):
152
+ return False
153
+ return str(spec.get("attention_backend") or "").upper() in ("MJOLNIR", "CUSTOM")
154
+
155
+
156
+ def config_attention_backend(path: Path) -> str | None:
157
+ """The attention backend a config selects (``attention-backend`` or
158
+ ``attention-config.backend``), upper-cased; None when unset."""
159
+ try:
160
+ data = yaml.safe_load(Path(path).read_text())
161
+ except Exception: # noqa: BLE001
162
+ return None
163
+ if not isinstance(data, dict):
164
+ return None
165
+ v = data.get("attention-backend")
166
+ if not v and isinstance(data.get("attention-config"), dict):
167
+ v = data["attention-config"].get("backend")
168
+ return str(v).upper() if v else None
169
+
170
+
171
+ # ── repo / resources ─────────────────────────────────────────────────────────
172
+
173
+
174
+ def bundled_root() -> Path:
175
+ """Resources shipped inside the wheel (configs, side stacks, HW script)."""
176
+ return Path(__file__).resolve().parent / "bundled"
177
+
178
+
179
+ def find_repo_root() -> Path:
180
+ """Locate the mjolnir repo: $MJOLNIR_REPO wins, else walk up from CWD
181
+ looking for ``docker/vllm-thor/Dockerfile``. Raises FileNotFoundError
182
+ outside a checkout (PyPI installs then run from bundled resources)."""
183
+ env = os.environ.get("MJOLNIR_REPO")
184
+ if env:
185
+ return Path(env)
186
+ cur = Path.cwd().resolve()
187
+ for cand in [cur, *cur.parents]:
188
+ if (cand / _REPO_MARKER).exists():
189
+ return cand
190
+ raise FileNotFoundError(
191
+ "mjolnir repo not found (run from inside a checkout or set $MJOLNIR_REPO); "
192
+ "this command needs the repo — clone https://github.com/thor-arena/mjolnir"
193
+ )
194
+
195
+
196
+ def repo_root_or_none() -> Path | None:
197
+ try:
198
+ return find_repo_root()
199
+ except FileNotFoundError:
200
+ return None
201
+
202
+
203
+ def resource_path(rel: str | Path) -> Path:
204
+ """A repo resource: the checkout's copy when in a checkout (so edits are
205
+ live), else the copy bundled in the wheel."""
206
+ root = repo_root_or_none()
207
+ if root is not None and (root / rel).exists():
208
+ return root / rel
209
+ return bundled_root() / rel
210
+
211
+
212
+ def configs_root() -> Path:
213
+ """The canonical model-config tree (repo ``configs/`` or the bundled copy)."""
214
+ return resource_path("configs")
215
+
216
+
217
+ def scan_all_configs(layout: RepoLayout | None = None) -> list[ConfigEntry]:
218
+ """Every config the CLI can serve: the repo's (or bundled) ``configs/`` +
219
+ the user-local dir. Local configs never shadow a canonical config with
220
+ the same ``(model, quant)`` — the reviewed copy wins."""
221
+ entries = scan_configs(configs_root())
222
+ seen = {(e.model, e.quant) for e in entries}
223
+ for e in scan_configs(user_configs_dir()):
224
+ if (e.model, e.quant) not in seen:
225
+ entries.append(ConfigEntry(model=e.model, quant=e.quant, path=e.path, source="local"))
226
+ seen.add((e.model, e.quant))
227
+ entries.sort(key=lambda e: (e.model, e.quant))
228
+ return entries
229
+
230
+
231
+ @dataclass
232
+ class RepoLayout:
233
+ """Where the CLI reads and writes: the checkout (if any), the user data
234
+ dir, and the benchmark tree (raws + history + charts)."""
235
+
236
+ repo_root: Path | None
237
+ data_dir: Path
238
+ state_file: Path
239
+ benchmarks_dir: Path
240
+ history_file: Path
241
+
242
+ @property
243
+ def is_repo(self) -> bool:
244
+ return self.repo_root is not None
245
+
246
+ @property
247
+ def raw_dir(self) -> Path:
248
+ return self.benchmarks_dir / "raw"
249
+
250
+ @property
251
+ def verify_dir(self) -> Path:
252
+ """Structured verify results: ``raw/verify/<stage>/<run-id>.json``."""
253
+ return self.raw_dir / "verify"
254
+
255
+ @property
256
+ def charts_dir(self) -> Path:
257
+ base = self.repo_root if self.repo_root is not None else self.benchmarks_dir
258
+ return base / "assets" / "benchmarks"
259
+
260
+ def require_repo(self, what: str) -> Path:
261
+ if self.repo_root is None:
262
+ raise FileNotFoundError(f"{what} needs a mjolnir checkout (run from inside one or set $MJOLNIR_REPO)")
263
+ return self.repo_root
264
+
265
+
266
+ def load_layout() -> RepoLayout:
267
+ root = repo_root_or_none()
268
+ data = Path(_env("MJOLNIR_DATA", "~/.local/share/mjolnir")).expanduser()
269
+ bench_default = (root / "benchmarks") if root is not None else (data / "benchmarks")
270
+ bench = Path(_env("MJOLNIR_BENCHMARKS", str(bench_default))).expanduser()
271
+ return RepoLayout(
272
+ repo_root=root,
273
+ data_dir=data,
274
+ state_file=Path(_env("MJOLNIR_STATE", str(Path.home() / ".mjolnir-state.json"))),
275
+ benchmarks_dir=bench,
276
+ history_file=Path(_env("MJOLNIR_HISTORY", str(bench / "history.jsonl"))).expanduser(),
277
+ )
278
+
279
+
280
+ @dataclass
281
+ class Settings:
282
+ """Resolved runtime settings for a command."""
283
+
284
+ model: str = field(default_factory=lambda: _env("MJOLNIR_MODEL", DEFAULT_MODEL))
285
+ quant: str = field(default_factory=lambda: _env("MJOLNIR_QUANT", DEFAULT_QUANT))
286
+ image: str = field(default_factory=lambda: _env("MJOLNIR_IMAGE", DEFAULT_IMAGE))
287
+ port: int = field(default_factory=lambda: _env_int("MJOLNIR_PORT", DEFAULT_PORT))
288
+ data_dir: Path = field(default_factory=lambda: Path("~/.local/share/mjolnir").expanduser())
289
+ label: str | None = None # explicit backend-label override (history log); else derived from quant
290
+
291
+ @property
292
+ def config_path(self) -> Path:
293
+ """The resolved config file: the canonical
294
+ ``configs/<model>/<quant>/config.yaml`` (repo or bundled) if present,
295
+ else the user-local one, else the canonical path (for the not-found
296
+ error)."""
297
+ canon = configs_root() / self.model / self.quant / "config.yaml"
298
+ if canon.exists():
299
+ return canon
300
+ local = user_configs_dir() / self.model / self.quant / "config.yaml"
301
+ if local.exists():
302
+ return local
303
+ return canon
304
+
305
+ @property
306
+ def attention_backend(self) -> str | None:
307
+ return config_attention_backend(self.config_path)
308
+
309
+ @property
310
+ def served_model(self) -> str:
311
+ """The name the served model answers to: ``served-model-name`` from
312
+ the config yaml, else its ``model`` field (vLLM's default served
313
+ name), else the baked-in constant."""
314
+ for key in ("served-model-name", "model"):
315
+ v = config_field(self.config_path, key)
316
+ if v:
317
+ return v
318
+ return SERVED_MODEL_NAME
319
+
320
+ @property
321
+ def base_url(self) -> str:
322
+ return f"http://127.0.0.1:{self.port}/v1"
323
+
324
+ @property
325
+ def metrics_url(self) -> str:
326
+ return f"http://127.0.0.1:{self.port}/metrics"
327
+
328
+ @property
329
+ def backend_label(self) -> str:
330
+ """Explicit --label override if set, else the friendly name for the
331
+ quant config (BACKEND_LABELS), else the quant string itself."""
332
+ return self.label or BACKEND_LABELS.get(self.quant, self.quant)
333
+
334
+
335
+ def resolve(cli_model: str | None, cli_quant: str | None, cli_image: str | None, cli_port: int | None) -> Settings:
336
+ """CLI flags > env vars > defaults."""
337
+ s = Settings()
338
+ if cli_model:
339
+ s.model = cli_model
340
+ if cli_quant:
341
+ s.quant = cli_quant
342
+ if cli_image:
343
+ s.image = cli_image
344
+ if cli_port:
345
+ s.port = cli_port
346
+ return s