@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

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 (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -1,235 +1,235 @@
1
- """The bench method as an OpenAI-compatible endpoint, at http://127.0.0.1:8123/v1.
2
-
3
- python gateway.py
4
-
5
- Ask for the model `bench/panel` and the prompt goes to the panel `route.py`
6
- would seat — primary, second opinion, two explorers — in parallel. The reply
7
- is every answer, each labelled by seat and model with a call id, including the
8
- seats that failed or came back empty: the panel's make-up is evidence about
9
- the roster, and a summary naming only the good answer hides it.
10
-
11
- bench/panel Mercury Decide picks the task shape, else general
12
- bench/panel-review the shape given: review, code, design, enumeration, …
13
-
14
- A request body may add `"with": ["<alias>"]` to seat a model in an explorer
15
- chair, as `route.py --with` does.
16
-
17
- THE LOOP
18
- --------
19
- Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
20
- moment it answers, so the explorer seats rotate even when nobody judges. The
21
- caller then checks the answers against the real code — agreement between seats
22
- is not a verdict, they fail together — and records one per call id:
23
-
24
- python metrics/offload.py verdict <call id> --useful --note "..."
25
- POST /bench/verdict {"call": "<call id>", "useful": true, "note": "..."}
26
-
27
- The verdict replaces the unjudged entry, and the next panel is seated from it.
28
-
29
- WHAT IT IS NOT
30
- --------------
31
- A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
32
- exists only for the multi-model work. Seats are called with `llm -m`, exactly
33
- as offloads always were, so endpoints, headers and per-model options all come
34
- from the same registry. Full prompts and answers go to `runs/gateway/`,
35
- gitignored. Bound to 127.0.0.1, like the dashboard.
36
- """
37
-
38
- from __future__ import annotations
39
-
40
- import datetime as dt
41
- import json
42
- import os
43
- import pathlib
44
- import secrets
45
- import subprocess
46
- import sys
47
- import time
48
- from concurrent.futures import ThreadPoolExecutor
49
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
50
-
51
- ROOT = pathlib.Path(__file__).resolve().parent
52
- PORT = 8123
53
- LOG_DIR = ROOT / "runs" / "gateway"
54
- SEAT_TIMEOUT = 900
55
- sys.path.insert(0, str(ROOT / "metrics"))
56
- sys.path.insert(0, str(ROOT / "suite"))
57
- import decide # noqa: E402
58
- import offload # noqa: E402
59
- import route # noqa: E402
60
-
61
- SEATS = [("primary", "primary"), ("second", "second opinion"),
62
- ("explorer", "explorer"), ("explorer2", "explorer 2")]
63
- # What the shape picker is asked to choose between. Each option carries a
64
- # description because bare names mislead it: Mercury Decide called "list every
65
- # place serve.py writes a file" `code` at 0.93 when given names alone.
66
- SHAPES = {
67
- "general": "A general question or task that fits none of the other kinds.",
68
- "design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
69
- "prose": "Writing or editing documentation or other text.",
70
- "review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
71
- "code": "Writing new code or changing existing code.",
72
- "enumeration": "Listing every place something happens in given code or files.",
73
- "quick": "A short, bounded task: classify, extract or summarise a small input.",
74
- }
75
- CONFIDENT = 0.6
76
- LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
77
-
78
-
79
- def flatten(messages: list[dict]) -> tuple[str, str]:
80
- """(system, prompt) for `llm`. A panel question is one prompt; a longer
81
- conversation is kept as labelled turns rather than silently truncated."""
82
- def text(m):
83
- c = m.get("content") or ""
84
- return c if isinstance(c, str) else "\n".join(
85
- p.get("text", "") for p in c if isinstance(p, dict))
86
- system = "\n\n".join(text(m) for m in messages if m.get("role") == "system")
87
- turns = [m for m in messages if m.get("role") != "system"]
88
- if len(turns) == 1:
89
- return system, text(turns[0])
90
- return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
91
-
92
-
93
- def pick_shape(prompt: str) -> tuple[str, str]:
94
- """(shape, how it was chosen). Falls back to general on any doubt."""
95
- picker = next((a for a, d in decide.load().items() if "choice" in d["question_types"]), None)
96
- if not picker:
97
- return "general", "no decision model onboarded"
98
- question = {"shape": {"type": "choice",
99
- "instructions": "What kind of task does this prompt ask for?",
100
- "criteria": SHAPES}}
101
- try:
102
- answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
103
- except Exception as exc: # noqa: BLE001
104
- return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
105
- conf = answer.get("confidence", 0)
106
- if answer.get("choice") not in SHAPES or conf < CONFIDENT:
107
- return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
108
- return answer["choice"], f"{picker}, {conf:.2f}"
109
-
110
-
111
- def ask_seat(alias: str, system: str, prompt: str) -> dict:
112
- cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
113
- started = time.monotonic()
114
- try:
115
- done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
116
- encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
117
- env={**os.environ, "PYTHONIOENCODING": "utf-8"})
118
- answer, error = done.stdout.strip(), (done.stderr.strip() if done.returncode else "")
119
- except subprocess.TimeoutExpired:
120
- answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
121
- status = "error" if error else ("empty" if not answer else "ok")
122
- return {"status": status, "answer": answer, "error": error[-500:],
123
- "seconds": round(time.monotonic() - started, 1)}
124
-
125
-
126
- def run_panel(model: str, messages: list[dict], nominated: list[str] | None = None) -> str:
127
- system, prompt = flatten(messages)
128
- if model == "bench/panel":
129
- shape, how = pick_shape(prompt)
130
- else:
131
- shape, how = model.removeprefix("bench/panel-"), "asked for by name"
132
- # `with` in the request body is route.py's --with: an explorer seat, never
133
- # the primary or second.
134
- p = route.panel(shape, large=len(prompt) > LARGE, nominated=nominated or None)
135
- seats = [(key, label, p[key]["model"]) for key, label in SEATS if p.get(key)]
136
- if not seats:
137
- raise LookupError(f"route.py seated nobody for shape {shape}")
138
- panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
139
- with ThreadPoolExecutor(max_workers=len(seats)) as pool:
140
- results = list(pool.map(lambda s: ask_seat(s[2], system, prompt), seats))
141
-
142
- task = " ".join(prompt.split())[:90] + f" (bench/panel, {shape})"
143
- LOG_DIR.mkdir(parents=True, exist_ok=True)
144
- record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
145
- "model": model, "shape": shape, "shape_by": how, "system": system,
146
- "prompt": prompt, "seats": []}
147
- out = [f"Bench panel {panel_id} · shape {shape} ({how}) · {len(seats)} seats\n"]
148
- for (key, label, alias), r in zip(seats, results):
149
- call = f"{panel_id}.{key}"
150
- offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
151
- + (f": {r['error'][:120]}" if r["error"] else ""), call)
152
- record["seats"].append({"call": call, "seat": label, "alias": alias, **r})
153
- body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
154
- "_empty reply — usually a token budget, not incapacity_")
155
- out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
156
- with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
157
- fh.write(json.dumps(record, ensure_ascii=False) + "\n")
158
- out.append("---\nRecord a verdict per seat once checked against the code:\n"
159
- "`python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
160
- return "\n".join(out)
161
-
162
-
163
- def models() -> list[str]:
164
- return ["bench/panel"] + [f"bench/panel-{s}" for s in SHAPES]
165
-
166
-
167
- class Handler(BaseHTTPRequestHandler):
168
- def _json(self, code: int, data: dict) -> None:
169
- body = json.dumps(data).encode()
170
- self.send_response(code)
171
- self.send_header("Content-Type", "application/json")
172
- self.send_header("Content-Length", str(len(body)))
173
- self.end_headers()
174
- self.wfile.write(body)
175
-
176
- def _error(self, code: int, message: str) -> None:
177
- self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}})
178
-
179
- def do_GET(self): # noqa: N802
180
- if self.path.rstrip("/") in ("/v1/models", "/models"):
181
- self._json(200, {"object": "list", "data": [
182
- {"id": m, "object": "model", "owned_by": "model-bench"} for m in models()]})
183
- else:
184
- self._error(404, "GET /v1/models is the only GET here")
185
-
186
- def do_POST(self): # noqa: N802
187
- try:
188
- body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
189
- except ValueError:
190
- return self._error(400, "body is not JSON")
191
- path = self.path.rstrip("/")
192
- if path == "/bench/verdict":
193
- try:
194
- e = offload.verdict(str(body.get("call", "")), body.get("useful"), body.get("note", ""))
195
- except KeyError as exc:
196
- return self._error(404, exc.args[0])
197
- return self._json(200, {"ok": True, "recorded": e})
198
- if path not in ("/v1/chat/completions", "/chat/completions"):
199
- return self._error(404, "POST /v1/chat/completions or /bench/verdict")
200
- model = body.get("model", "")
201
- if model not in models():
202
- return self._error(404, f"{model!r} is not served here; this endpoint only runs"
203
- f" panels: {', '.join(models())}")
204
- try:
205
- content = run_panel(model, body.get("messages") or [], body.get("with"))
206
- except LookupError as exc:
207
- return self._error(503, str(exc))
208
- reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
209
- if not body.get("stream"):
210
- return self._json(200, {
211
- "id": reply_id, "object": "chat.completion", "created": created, "model": model,
212
- "choices": [{"index": 0, "finish_reason": "stop",
213
- "message": {"role": "assistant", "content": content}}]})
214
- # A panel finishes all at once, so a streaming client gets it as one chunk.
215
- self.send_response(200)
216
- self.send_header("Content-Type", "text/event-stream")
217
- self.end_headers()
218
- for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
219
- chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
220
- "model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
221
- self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
222
- self.wfile.write(b"data: [DONE]\n\n")
223
-
224
- def log_message(self, fmt, *args): # quieter than the default
225
- sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
226
-
227
-
228
- def main() -> None:
229
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
230
- print(f"bench gateway on http://127.0.0.1:{PORT}/v1 — models: {', '.join(models())}")
231
- ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
232
-
233
-
234
- if __name__ == "__main__":
235
- main()
1
+ """The bench method as an OpenAI-compatible endpoint, at http://127.0.0.1:8123/v1.
2
+
3
+ python gateway.py
4
+
5
+ Ask for the model `bench/panel` and the prompt goes to the panel `route.py`
6
+ would seat — primary, second opinion, two explorers — in parallel. The reply
7
+ is every answer, each labelled by seat and model with a call id, including the
8
+ seats that failed or came back empty: the panel's make-up is evidence about
9
+ the roster, and a summary naming only the good answer hides it.
10
+
11
+ bench/panel Mercury Decide picks the task shape, else general
12
+ bench/panel-review the shape given: review, code, design, enumeration, …
13
+
14
+ A request body may add `"with": ["<alias>"]` to seat a model in an explorer
15
+ chair, as `route.py --with` does.
16
+
17
+ THE LOOP
18
+ --------
19
+ Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
20
+ moment it answers, so the explorer seats rotate even when nobody judges. The
21
+ caller then checks the answers against the real code — agreement between seats
22
+ is not a verdict, they fail together — and records one per call id:
23
+
24
+ python metrics/offload.py verdict <call id> --useful --note "..."
25
+ POST /bench/verdict {"call": "<call id>", "useful": true, "note": "..."}
26
+
27
+ The verdict replaces the unjudged entry, and the next panel is seated from it.
28
+
29
+ WHAT IT IS NOT
30
+ --------------
31
+ A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
32
+ exists only for the multi-model work. Seats are called with `llm -m`, exactly
33
+ as offloads always were, so endpoints, headers and per-model options all come
34
+ from the same registry. Full prompts and answers go to `runs/gateway/`,
35
+ gitignored. Bound to 127.0.0.1, like the dashboard.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import datetime as dt
41
+ import json
42
+ import os
43
+ import pathlib
44
+ import secrets
45
+ import subprocess
46
+ import sys
47
+ import time
48
+ from concurrent.futures import ThreadPoolExecutor
49
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
50
+
51
+ ROOT = pathlib.Path(__file__).resolve().parent
52
+ PORT = 8123
53
+ LOG_DIR = ROOT / "runs" / "gateway"
54
+ SEAT_TIMEOUT = 900
55
+ sys.path.insert(0, str(ROOT / "metrics"))
56
+ sys.path.insert(0, str(ROOT / "suite"))
57
+ import decide # noqa: E402
58
+ import offload # noqa: E402
59
+ import route # noqa: E402
60
+
61
+ SEATS = [("primary", "primary"), ("second", "second opinion"),
62
+ ("explorer", "explorer"), ("explorer2", "explorer 2")]
63
+ # What the shape picker is asked to choose between. Each option carries a
64
+ # description because bare names mislead it: Mercury Decide called "list every
65
+ # place serve.py writes a file" `code` at 0.93 when given names alone.
66
+ SHAPES = {
67
+ "general": "A general question or task that fits none of the other kinds.",
68
+ "design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
69
+ "prose": "Writing or editing documentation or other text.",
70
+ "review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
71
+ "code": "Writing new code or changing existing code.",
72
+ "enumeration": "Listing every place something happens in given code or files.",
73
+ "quick": "A short, bounded task: classify, extract or summarise a small input.",
74
+ }
75
+ CONFIDENT = 0.6
76
+ LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
77
+
78
+
79
+ def flatten(messages: list[dict]) -> tuple[str, str]:
80
+ """(system, prompt) for `llm`. A panel question is one prompt; a longer
81
+ conversation is kept as labelled turns rather than silently truncated."""
82
+ def text(m):
83
+ c = m.get("content") or ""
84
+ return c if isinstance(c, str) else "\n".join(
85
+ p.get("text", "") for p in c if isinstance(p, dict))
86
+ system = "\n\n".join(text(m) for m in messages if m.get("role") == "system")
87
+ turns = [m for m in messages if m.get("role") != "system"]
88
+ if len(turns) == 1:
89
+ return system, text(turns[0])
90
+ return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
91
+
92
+
93
+ def pick_shape(prompt: str) -> tuple[str, str]:
94
+ """(shape, how it was chosen). Falls back to general on any doubt."""
95
+ picker = next((a for a, d in decide.load().items() if "choice" in d["question_types"]), None)
96
+ if not picker:
97
+ return "general", "no decision model onboarded"
98
+ question = {"shape": {"type": "choice",
99
+ "instructions": "What kind of task does this prompt ask for?",
100
+ "criteria": SHAPES}}
101
+ try:
102
+ answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
103
+ except Exception as exc: # noqa: BLE001
104
+ return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
105
+ conf = answer.get("confidence", 0)
106
+ if answer.get("choice") not in SHAPES or conf < CONFIDENT:
107
+ return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
108
+ return answer["choice"], f"{picker}, {conf:.2f}"
109
+
110
+
111
+ def ask_seat(alias: str, system: str, prompt: str) -> dict:
112
+ cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
113
+ started = time.monotonic()
114
+ try:
115
+ done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
116
+ encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
117
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"})
118
+ answer, error = done.stdout.strip(), (done.stderr.strip() if done.returncode else "")
119
+ except subprocess.TimeoutExpired:
120
+ answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
121
+ status = "error" if error else ("empty" if not answer else "ok")
122
+ return {"status": status, "answer": answer, "error": error[-500:],
123
+ "seconds": round(time.monotonic() - started, 1)}
124
+
125
+
126
+ def run_panel(model: str, messages: list[dict], nominated: list[str] | None = None) -> str:
127
+ system, prompt = flatten(messages)
128
+ if model == "bench/panel":
129
+ shape, how = pick_shape(prompt)
130
+ else:
131
+ shape, how = model.removeprefix("bench/panel-"), "asked for by name"
132
+ # `with` in the request body is route.py's --with: an explorer seat, never
133
+ # the primary or second.
134
+ p = route.panel(shape, large=len(prompt) > LARGE, nominated=nominated or None)
135
+ seats = [(key, label, p[key]["model"]) for key, label in SEATS if p.get(key)]
136
+ if not seats:
137
+ raise LookupError(f"route.py seated nobody for shape {shape}")
138
+ panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
139
+ with ThreadPoolExecutor(max_workers=len(seats)) as pool:
140
+ results = list(pool.map(lambda s: ask_seat(s[2], system, prompt), seats))
141
+
142
+ task = " ".join(prompt.split())[:90] + f" (bench/panel, {shape})"
143
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
144
+ record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
145
+ "model": model, "shape": shape, "shape_by": how, "system": system,
146
+ "prompt": prompt, "seats": []}
147
+ out = [f"Bench panel {panel_id} · shape {shape} ({how}) · {len(seats)} seats\n"]
148
+ for (key, label, alias), r in zip(seats, results):
149
+ call = f"{panel_id}.{key}"
150
+ offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
151
+ + (f": {r['error'][:120]}" if r["error"] else ""), call)
152
+ record["seats"].append({"call": call, "seat": label, "alias": alias, **r})
153
+ body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
154
+ "_empty reply — usually a token budget, not incapacity_")
155
+ out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
156
+ with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
157
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
158
+ out.append("---\nRecord a verdict per seat once checked against the code:\n"
159
+ "`python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
160
+ return "\n".join(out)
161
+
162
+
163
+ def models() -> list[str]:
164
+ return ["bench/panel"] + [f"bench/panel-{s}" for s in SHAPES]
165
+
166
+
167
+ class Handler(BaseHTTPRequestHandler):
168
+ def _json(self, code: int, data: dict) -> None:
169
+ body = json.dumps(data).encode()
170
+ self.send_response(code)
171
+ self.send_header("Content-Type", "application/json")
172
+ self.send_header("Content-Length", str(len(body)))
173
+ self.end_headers()
174
+ self.wfile.write(body)
175
+
176
+ def _error(self, code: int, message: str) -> None:
177
+ self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}})
178
+
179
+ def do_GET(self): # noqa: N802
180
+ if self.path.rstrip("/") in ("/v1/models", "/models"):
181
+ self._json(200, {"object": "list", "data": [
182
+ {"id": m, "object": "model", "owned_by": "model-bench"} for m in models()]})
183
+ else:
184
+ self._error(404, "GET /v1/models is the only GET here")
185
+
186
+ def do_POST(self): # noqa: N802
187
+ try:
188
+ body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
189
+ except ValueError:
190
+ return self._error(400, "body is not JSON")
191
+ path = self.path.rstrip("/")
192
+ if path == "/bench/verdict":
193
+ try:
194
+ e = offload.verdict(str(body.get("call", "")), body.get("useful"), body.get("note", ""))
195
+ except KeyError as exc:
196
+ return self._error(404, exc.args[0])
197
+ return self._json(200, {"ok": True, "recorded": e})
198
+ if path not in ("/v1/chat/completions", "/chat/completions"):
199
+ return self._error(404, "POST /v1/chat/completions or /bench/verdict")
200
+ model = body.get("model", "")
201
+ if model not in models():
202
+ return self._error(404, f"{model!r} is not served here; this endpoint only runs"
203
+ f" panels: {', '.join(models())}")
204
+ try:
205
+ content = run_panel(model, body.get("messages") or [], body.get("with"))
206
+ except LookupError as exc:
207
+ return self._error(503, str(exc))
208
+ reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
209
+ if not body.get("stream"):
210
+ return self._json(200, {
211
+ "id": reply_id, "object": "chat.completion", "created": created, "model": model,
212
+ "choices": [{"index": 0, "finish_reason": "stop",
213
+ "message": {"role": "assistant", "content": content}}]})
214
+ # A panel finishes all at once, so a streaming client gets it as one chunk.
215
+ self.send_response(200)
216
+ self.send_header("Content-Type", "text/event-stream")
217
+ self.end_headers()
218
+ for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
219
+ chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
220
+ "model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
221
+ self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
222
+ self.wfile.write(b"data: [DONE]\n\n")
223
+
224
+ def log_message(self, fmt, *args): # quieter than the default
225
+ sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
226
+
227
+
228
+ def main() -> None:
229
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
230
+ print(f"bench gateway on http://127.0.0.1:{PORT}/v1 — models: {', '.join(models())}")
231
+ ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()