@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1

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 (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -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 +295 -295
  22. package/metrics/client.py +394 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -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/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -579
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -65
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -106
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -81
  62. package/scripts/test_mcp.py +178 -178
  63. package/scripts/test_merged.py +214 -214
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -438
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3764
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1042
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -425
  93. package/ui/bench.js +1756 -1576
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
package/gateway.py CHANGED
@@ -1,755 +1,755 @@
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 a COLLECTION by name — `bench/core`, `bench/reviewers` — and the prompt
6
- goes in parallel to every member of it plus explorer seats `route.py` fills by
7
- staleness, so the roster keeps getting evidence even when the same collection
8
- is called every day:
9
-
10
- explorers = min(max_explorers, max_calls - members) # defaults 2 and 5
11
-
12
- Collections and those limits live in `config/collections.json` and
13
- `config/gateway.json` (see `metrics/bench_collections.py`), edited from the
14
- dashboard. There is no default: a request that names no collection, or one
15
- that does not exist, is refused with every collection's description and tags,
16
- so the caller can choose — `GET /v1/models` carries the same.
17
-
18
- The reply is every answer, each labelled by seat and model with a call id,
19
- including seats that failed or came back empty: who answered is evidence about
20
- the roster, and a summary naming only the good answer hides it.
21
-
22
- Unless the reply is `merged` (a collection's `reply`, or per request a
23
- `:merged` suffix on the model or `"bench": {"reply": "merged"}`; `:all` the
24
- other way): then, after the panel, one more model writes a single answer
25
- saying where the seats agreed, where they disagreed and who said what, for a
26
- client that can show only one. It has its own call id, so a verdict can judge
27
- the merge, and the `bench` field still carries every seat. If no merger has
28
- the context for the merge, or it fails, the reply is every answer with a
29
- one-line note: the panel the caller waited for is never lost.
30
-
31
- A collection's `shape` (review, design, …) decides which models may fill the
32
- explorer seats. Left empty, a decision model labels the prompt when one that
33
- takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
34
- the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
35
- model in an explorer chair, as `route.py --with` does.
36
-
37
- A retired model (`config/retired.json`) is never called: retiring takes it out
38
- of every collection, and one a collection still names — as a member, `with`
39
- or its merger — is skipped and named in `bench.skipped`, its seat going to an
40
- explorer and the merge to a merger chosen by verdicts.
41
-
42
- THE LOOP
43
- --------
44
- Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
45
- moment it answers, so the explorer seats rotate even when nobody judges. The
46
- caller then checks the answers against the real code — agreement between seats
47
- is not a verdict, they fail together — and records one per call id:
48
-
49
- python metrics/offload.py verdict <call id> --useful --note "..." \\
50
- --real "<a finding it got right>" --false "<a claim it made that is untrue>"
51
- POST /bench/verdict {"call": "<call id>", "useful": true, "note": "...",
52
- "real": ["..."], "false": ["..."]}
53
-
54
- The real/false claims are what `suite/mine.py` turns into suite tasks.
55
-
56
- The verdict replaces the unjudged entry, and route.py's evidence — which
57
- explorers are due, which models earn a place in a collection — reads it.
58
-
59
- IMAGES
60
- ------
61
- A request whose messages carry `image_url` parts (http(s) or data: URLs) goes
62
- to each seat with its messages as sent, through the built-in client whatever
63
- BENCH_CLIENT says (`llm -m` takes a prompt). Only models the catalog says take
64
- images sit (`cannot_see`): a member that cannot, or that the catalog knows
65
- nothing about, is skipped and named in `bench.skipped` with why; explorers
66
- are drawn from models that can, under the shape `vision` unless the
67
- collection sets one; with no seat that can see, the request is refused (400)
68
- rather than answered blind. Each image counts IMAGE_TOKENS in the size check.
69
- Wherever the request is written down (the call log, offloads, learned
70
- limits, error text, the dashboard) an image is a placeholder, never its
71
- bytes. The merger gets the request as text with those placeholders, plus
72
- the seats' answers: its job is to reconcile what the seats said, not to look
73
- again, so any model can merge and a disagreement about the picture is
74
- reported rather than settled.
75
-
76
- WHAT IT IS NOT
77
- --------------
78
- A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
79
- exists only for the multi-model work. Seats are called with `llm -m`, exactly
80
- as offloads always were, or the built-in client (BENCH_CLIENT), so endpoints,
81
- headers and per-model options all come from the same registry. Full prompts
82
- and answers go to `runs/gateway/`, gitignored, with the name of the gateway
83
- key that asked. Bound to 127.0.0.1, like the dashboard (BENCH_BIND and
84
- BENCH_GATEWAY_PORT change that; beyond this machine it asks for a gateway key,
85
- metrics/guard.py, which also covers keys on this machine, scopes, caps and
86
- CORS). The dashboard answers these same routes on its own port.
87
- """
88
-
89
- from __future__ import annotations
90
-
91
- import datetime as dt
92
- import json
93
- import os
94
- import pathlib
95
- import re
96
- import secrets
97
- import subprocess
98
- import sys
99
- import time
100
- import urllib.parse
101
- from concurrent.futures import ThreadPoolExecutor
102
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
103
-
104
- ROOT = pathlib.Path(__file__).resolve().parent
105
- sys.path.insert(0, str(ROOT / "metrics"))
106
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
107
- PORT = int(os.environ.get("BENCH_GATEWAY_PORT") or 8123)
108
- LOG_DIR = DATA / "runs" / "gateway"
109
- # The suite's own timeout. Above the slowest real answer seen (383 s); at 900
110
- # one stuck explorer held a whole request for fifteen minutes.
111
- SEAT_TIMEOUT = 420
112
- sys.path.insert(0, str(ROOT / "metrics"))
113
- sys.path.insert(0, str(ROOT / "suite"))
114
- import bench_collections # noqa: E402
115
- import client # noqa: E402
116
- import decide # noqa: E402
117
- import guard # noqa: E402
118
- import offload # noqa: E402
119
- import route # noqa: E402
120
-
121
- # What the shape picker is asked to choose between. Each option carries a
122
- # description because bare names mislead it: Mercury Decide called "list every
123
- # place serve.py writes a file" `code` at 0.93 when given names alone.
124
- SHAPES = {
125
- "general": "A general question or task that fits none of the other kinds.",
126
- "design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
127
- "prose": "Writing or editing documentation or other text.",
128
- "review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
129
- "code": "Writing new code or changing existing code.",
130
- "enumeration": "Listing every place something happens in given code or files.",
131
- "quick": "A short, bounded task: classify, extract or summarise a small input.",
132
- }
133
- CONFIDENT = 0.6
134
- LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
135
- # Bytes. A million-token prompt is about 4 MB, and a photo sent as a data URL
136
- # is 4/3 of its file. 20 MB is the tightest whole-request limit among the big
137
- # providers (Gemini's for inline data), so what passes here they take too.
138
- MAX_BODY = 20_000_000
139
- TOO_BIG = (" Send images as http(s) URLs, or smaller ones: models scale a photo down to about"
140
- " 1,500-2,000 pixels on its long side anyway.")
141
- IMAGE_TOKENS = 2_500 # per image, in the size check (estimate_tokens says why)
142
- IMAGE_URL = re.compile(r"(https?://[^\s/?#]+|data:image/[\w.+-]+;base64,)", re.I)
143
- DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
144
- URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
145
- BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
146
- HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
147
- SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
148
- # A merge that reads as consensus hides the dissent the bench runs on, so the
149
- # merger is told to keep who said what.
150
- MERGE_SYSTEM = ("Several models answered the same request. Write one answer to it from theirs: say where"
151
- " they agreed, where they disagreed, and who said what, naming each model as labelled."
152
- " Keep every dissent, and do not present a claim as settled because several made it.")
153
-
154
-
155
- def flatten(messages: list[dict]) -> tuple[str, str]:
156
- """(system, prompt) for `llm`. A panel question is one prompt; a longer
157
- conversation is kept as labelled turns rather than silently truncated.
158
- An image is its placeholder: this text is what gets written down."""
159
- def text(m):
160
- c = m.get("content") or ""
161
- return c if isinstance(c, str) else "\n".join(
162
- placeholder(_url(p)) if p.get("type") == "image_url" else str(p.get("text") or "")
163
- for p in c if isinstance(p, dict))
164
- # Newer OpenAI SDKs send "developer" where older ones send "system".
165
- lead = ("system", "developer")
166
- system = "\n\n".join(text(m) for m in messages if m.get("role") in lead)
167
- turns = [m for m in messages if m.get("role") not in lead]
168
- if len(turns) == 1:
169
- return system, text(turns[0])
170
- return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
171
-
172
-
173
- def _url(part: dict) -> str:
174
- """An image part's URL: {"image_url": {"url": ...}} as OpenAI sends it, or a bare string."""
175
- u = part.get("image_url")
176
- u = u.get("url") if isinstance(u, dict) else u
177
- return u if isinstance(u, str) else ""
178
-
179
-
180
- def images(messages: list[dict]) -> list[str]:
181
- """Every image part's URL, in order. ValueError for one no seat could be
182
- sent: the gateway never fetches an image, the upstream does, so only
183
- http(s) URLs and inline base64 images pass; and an image in another API's
184
- shape is refused rather than dropped, which would have a seat answer blind."""
185
- urls = []
186
- for m in messages:
187
- parts = m.get("content")
188
- for p in parts if isinstance(parts, list) else ():
189
- kind = p.get("type") if isinstance(p, dict) else None
190
- if kind in ("image", "input_image"):
191
- raise ValueError(f'a "{kind}" part is another API\'s shape; send each image as'
192
- ' {"type": "image_url", "image_url": {"url": "<http(s) or data: URL>"}}')
193
- if kind == "image_url":
194
- url = _url(p)
195
- if not IMAGE_URL.match(url):
196
- raise ValueError("an image_url must be an http(s) URL or a data:image/...;base64, URL,"
197
- f" not {placeholder(url) if url else 'empty'}")
198
- urls.append(url)
199
- return urls
200
-
201
-
202
- def placeholder(url: str) -> str:
203
- """How an image is written down (the call log, offloads, the dashboard,
204
- error text, the merger's copy of the request), never its bytes: a data
205
- URL by its size, an http URL without its query (a signed URL's signature
206
- is a credential) or a user:password."""
207
- if url[:5].lower() == "data:":
208
- return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
209
- u = urllib.parse.urlsplit(url)
210
- return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
211
-
212
-
213
- def redact(text: str) -> str:
214
- """`text` with each inline image as its placeholder, every http(s) URL
215
- without its query or user:password, and any other long base64 run as its
216
- size: an upstream may echo the request in an error, in whatever form, and
217
- errors are logged, shown and learned from."""
218
- def bare(m):
219
- try:
220
- u = urllib.parse.urlsplit(m.group())
221
- except ValueError: # an unparseable netloc, such as "[::1"
222
- return "[url]"
223
- return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
224
- text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
225
- return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
226
-
227
-
228
- def cannot_see(alias: str) -> str:
229
- """Why an image cannot be put to `alias`, or "" when it can. The catalog
230
- decides (metrics/catalog.py: the model's input types, your Vision / OCR
231
- override in its model popup, a capability seen working). A model the
232
- catalog knows nothing about does not sit either: one that cannot see
233
- answers anyway, describing a photo it never got, and the override is one
234
- click."""
235
- import catalog
236
- import endpoints
237
- if endpoints.entry_of(alias).get("api") == "responses":
238
- return "reached through the Responses API, which the gateway sends no images to yet"
239
- rec = catalog.info(alias)
240
- if rec.get("vision"):
241
- return ""
242
- if rec.get("vision") is None:
243
- return "the catalog does not say whether it takes images; tick Vision / OCR in its model popup if it does"
244
- return f"does not take images (says {rec['sources'].get('vision', 'the catalog')})"
245
-
246
-
247
- def pick_shape(prompt: str) -> tuple[str, str]:
248
- """(shape, how it was chosen). Falls back to general on any doubt."""
249
- try:
250
- held = set(json.loads((DATA / "config" / "model-roles.json").read_text(encoding="utf-8"))
251
- .get("ad_hoc", []))
252
- except (OSError, ValueError, AttributeError):
253
- return "general", "config/model-roles.json unreadable"
254
- picker = next((a for a, d in decide.load().items()
255
- if "choice" in d["question_types"] and a not in held), None)
256
- if not picker:
257
- return "general", "no decision model onboarded"
258
- question = {"shape": {"type": "choice",
259
- "instructions": "What kind of task does this prompt ask for?",
260
- "criteria": SHAPES}}
261
- try:
262
- answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
263
- except Exception as exc: # noqa: BLE001
264
- return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
265
- conf = answer.get("confidence", 0)
266
- if answer.get("choice") not in SHAPES or conf < CONFIDENT:
267
- return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
268
- return answer["choice"], f"{picker}, {conf:.2f}"
269
-
270
-
271
- def retired() -> dict[str, dict]:
272
- """config/retired.json: alias -> its record (when, why). Retiring a model
273
- takes it out of every collection (serve.remove_model); this is for a
274
- collection that still names one -- a hand edit, or one saved before that
275
- -- so the seat is skipped with a reason instead of failing on the call."""
276
- try:
277
- data = json.loads((DATA / "config" / "retired.json").read_text(encoding="utf-8"))
278
- except (OSError, ValueError):
279
- return {}
280
- return data if isinstance(data, dict) else {}
281
-
282
-
283
- def retired_why(entry: dict) -> str:
284
- when = str(entry.get("at", ""))[:10] if isinstance(entry, dict) else ""
285
- return (f"retired from the bench{' on ' + when if when else ''}; take it out of the collection"
286
- " (Restore in its model popup brings it back)")
287
-
288
-
289
- def retiring() -> set[str]:
290
- """Aliases not to spend an explorer seat on: those on a retiring alert
291
- (failing their probes; 2026-10-16 both explorers of a bench/core call were
292
- and both failed), and those cooling down after a rate limit, until the
293
- wait the provider stated, or 30 minutes (metrics/learn.py)."""
294
- out: set[str] = set()
295
- try:
296
- import alerts
297
- out |= {a.get("alias", "") for a in alerts.active().values() if a.get("kind") == "retiring"}
298
- except Exception: # noqa: BLE001
299
- pass
300
- try:
301
- import learn
302
- out |= set(learn.cooldowns())
303
- except Exception: # noqa: BLE001
304
- pass
305
- return out
306
-
307
-
308
- def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None = None) -> dict:
309
- """One seat's answer. `messages`: the request as sent, when it carries
310
- images; it goes through the built-in client whatever BENCH_CLIENT says,
311
- since `llm -m` takes a prompt, not inline images."""
312
- cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
313
- started = time.monotonic()
314
- if messages is not None or client.backend(alias) == "builtin":
315
- # No subprocess, and no shared llm log for parallel seats to lock.
316
- res = client.chat(alias, prompt, system, SEAT_TIMEOUT, messages=messages)
317
- answer = res["text"]
318
- error = res["error"] or (f"no answer in {SEAT_TIMEOUT}s" if res["timed_out"] else "")
319
- else:
320
- answer, error = _ask_llm(cmd, prompt)
321
- # A quota or credit notice delivered as the reply's text, with a success
322
- # exit, is not an answer (suite/run.py PROVIDER_NOTICES).
323
- from run import provider_notice
324
- if not error and (notice := provider_notice(answer)):
325
- error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
326
- if messages is not None:
327
- # Before the error is cut, logged or learned from. ponytail: the answer
328
- # is not redacted; a seat gets an image as pixels, not as its URL or bytes.
329
- error = redact(error)
330
- if error:
331
- # A stated context window, rate limit or wait is kept (metrics/learn.py);
332
- # a rate limit also cools the model, so explorer seats skip it.
333
- try:
334
- import learn
335
- learn.record(alias, error, "gateway")
336
- except Exception: # noqa: BLE001
337
- pass
338
- status = "error" if error else ("empty" if not answer else "ok")
339
- return {"status": status, "answer": answer, "error": error[-500:],
340
- "seconds": round(time.monotonic() - started, 1)}
341
-
342
-
343
- def _ask_llm(cmd: list[str], prompt: str) -> tuple[str, str]:
344
- """(answer, error) from the `llm` CLI."""
345
- try:
346
- done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
347
- encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
348
- env={**os.environ, "PYTHONIOENCODING": "utf-8"})
349
- answer = done.stdout.strip()
350
- # A non-zero exit is an error even when stderr is silent, unless it is
351
- # only llm failing to log an answer it printed (client.py).
352
- failed = done.returncode and not client.llm_log_failed_only(done.stdout, done.stderr)
353
- error = (done.stderr.strip() or f"llm exited {done.returncode}") if failed else ""
354
- except subprocess.TimeoutExpired:
355
- answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
356
- except Exception as exc: # noqa: BLE001
357
- # One seat failing to start must not lose the other seats' records.
358
- answer, error = "", f"{type(exc).__name__}: {exc}"
359
- return answer, error
360
-
361
-
362
- class TooLarge(ValueError):
363
- """The prompt does not fit a member's context window (HTTP 413)."""
364
-
365
-
366
- def estimate_tokens(text: str) -> int:
367
- """Characters / 4 plus 10%: tokenizers differ by family, so this errs high.
368
- The windows it is compared with are corrected by learned 'maximum context
369
- length' errors (metrics/learn.py). An image adds IMAGE_TOKENS whatever its
370
- size, which errs high too: OpenAI's, Anthropic's and Gemini's models spend
371
- up to about 1,600 on one at their usual detail. Qwen and Llama vision
372
- models can spend more on a very large image; a learned error corrects that."""
373
- return int(len(text) / 4 * 1.1)
374
-
375
-
376
- def too_small(needed: int, aliases) -> dict[str, int]:
377
- """Aliases whose KNOWN context window is under `needed`, with the window."""
378
- import catalog
379
- out = {}
380
- for a in aliases:
381
- cw = catalog.info(a).get("context_window")
382
- if cw and cw < needed:
383
- out[a] = cw
384
- return out
385
-
386
-
387
- def record_seat(task: str, call: str, label: str, alias: str, r: dict) -> dict:
388
- """Log one answer as an unjudged offload, the moment it is in, so explorer
389
- rotation counts it even if nobody judges; returns the seat as logged."""
390
- offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
391
- + (f": {r['error'][:120]}" if r["error"] else ""), call)
392
- return {"call": call, "seat": label, "alias": alias, **r}
393
-
394
-
395
- def merger_candidates(collection: dict, members: list[str], shape: str, large: bool):
396
- """Who may merge, best first. The collection's `merger` alone when set;
397
- else the members with verdicts on this shape, most useful first, then the
398
- router's primary, then the other members (a fresh install has neither
399
- verdicts nor suite scores). A generator, so the router is asked only if
400
- no member with a record fits."""
401
- if collection.get("merger"):
402
- yield collection["merger"]
403
- return
404
- record: dict[str, list[int]] = {}
405
- for e in offload.entries():
406
- # The shape ends each seat's task as run_collection writes it: "(bench/<name>, <shape>)".
407
- if e["model"] in members and e.get("useful") is not None and e.get("task", "").endswith(f", {shape})"):
408
- r = record.setdefault(e["model"], [0, 0])
409
- r[0], r[1] = r[0] + bool(e["useful"]), r[1] + 1
410
- # Useful verdicts counted, not a ratio, as route.py counts findings: one
411
- # lucky verdict must not outrank a long good record.
412
- yield from sorted(record, key=lambda a: (-record[a][0], -record[a][0] / record[a][1]))
413
- try:
414
- primary = route.panel(shape, large=large)["primary"]
415
- except Exception: # noqa: BLE001 -- the members can still merge
416
- primary = None
417
- if primary:
418
- yield primary["model"]
419
- yield from members
420
-
421
-
422
- def merge(collection: dict, members: list[str], shape: str, whole: str, seats: list[dict],
423
- task: str, panel_id: str) -> tuple[dict | None, str]:
424
- """The merger's seat, and why the reply falls back to every answer ("" if
425
- it does not). The merger reads the request and every answer, so it gets
426
- the members' size check; a failed merger is still logged like a seat."""
427
- answered = [s for s in seats if s["status"] == "ok"]
428
- if not answered:
429
- return None, "no seat answered"
430
- text = f"THE REQUEST\n\n{whole}\n\nTHE ANSWERS\n\n" + "\n\n".join(
431
- f"## {s['seat']} — {s['alias']}\n\n{s['answer']}" for s in answered)
432
- needed = estimate_tokens(f"{MERGE_SYSTEM}\n\n{text}")
433
- alias = next((a for a in merger_candidates(collection, members, shape, len(text) > LARGE)
434
- if not too_small(needed, [a])), None)
435
- if alias is None:
436
- return None, f"no merger has the context window for the merge (about {needed:,} tokens)"
437
- seat = record_seat(task, f"{panel_id}.merger", "merger", alias, ask_seat(alias, MERGE_SYSTEM, text))
438
- if seat["status"] != "ok":
439
- return seat, f"the merger {alias} came back {seat['status']}" + (f": {seat['error'][:120]}" if seat["error"] else "")
440
- return seat, ""
441
-
442
-
443
- def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
444
- reply: str = "", who: str = "local") -> tuple[str, dict]:
445
- """The panel's reply as markdown, and the same seats as data for the
446
- response's `bench` field. `reply`: the request's mode, "" for the
447
- collection's own. `who`: the gateway key that asked, for the call log."""
448
- pictures = images(messages)
449
- system, prompt = flatten(messages)
450
- if not prompt.strip():
451
- raise ValueError("no user message to put to the collection")
452
- collection = bench_collections.load()[name]
453
- s = bench_collections.settings()
454
- raw = collection.get("members") or []
455
- # The editor dedupes and caps; a hand-edited file may not have.
456
- members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
457
- # A RETIRED MODEL IS NOT CALLED: it is no longer registered, so the call
458
- # could only fail ("Unknown model"). Skipped, and named, before the cap so
459
- # it frees its seat for the next member or an explorer. Retiring takes it
460
- # out of the collection, so this is a collection that still names one.
461
- gone = retired()
462
- skipped = [{"seat": "member", "alias": a, "why": retired_why(gone[a])} for a in members if a in gone]
463
- members = [a for a in members if a not in gone][:s["max_calls"]]
464
- # A member named in `with` is already seated; it must not use up the slot.
465
- nominated = [a for a in dict.fromkeys(nominated or []) if a not in members]
466
- skipped += [{"seat": "with", "alias": a, "why": retired_why(gone[a])} for a in nominated
467
- if a in gone and a not in {k["alias"] for k in skipped}]
468
- nominated = [a for a in nominated if a not in gone]
469
- merged = (reply or collection.get("reply") or "all") == "merged"
470
- if collection.get("merger") in gone:
471
- if merged:
472
- skipped.append({"seat": "merger", "alias": collection["merger"],
473
- "why": retired_why(gone[collection["merger"]]) + "; the merger is chosen by verdicts"})
474
- collection = {**collection, "merger": ""}
475
- n_explorers = bench_collections.explorers_for(len(members), s)
476
- # AN IMAGE GOES ONLY TO A MODEL THAT CAN SEE IT: one that cannot would
477
- # answer anyway, about a picture it never got. Skipped, and named.
478
- skipped += [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
479
- for a in group if pictures and (why := cannot_see(a))]
480
- unseen = {k["alias"] for k in skipped}
481
- sitting = [a for a in members if a not in unseen]
482
- nominated = [a for a in nominated if a not in unseen]
483
- # The system text goes to every seat too, so it counts for shape and size.
484
- whole = f"{system}\n\n{prompt}".strip()
485
- # REFUSE WHAT A MEMBER CANNOT READ, so the caller can trim the prompt or
486
- # pick another collection instead of collecting a seat's context error.
487
- needed = estimate_tokens(whole) + IMAGE_TOKENS * len(pictures)
488
- small = too_small(needed, sitting)
489
- if small:
490
- windows = ", ".join(f"{a} {cw:,}" for a, cw in sorted(small.items(), key=lambda kv: kv[1]))
491
- counted = f" (each image counted as {IMAGE_TOKENS:,})" if pictures else ""
492
- raise TooLarge(f"prompt is about {needed:,} tokens{counted}; too large for {windows} (context"
493
- f" windows) in bench/{name}. Trim it under {min(small.values()):,} tokens,"
494
- " or choose a collection whose members have larger windows"
495
- " (GET /v1/models lists each collection's context_window).")
496
- if pictures and not collection.get("shape"):
497
- shape, how = "vision", "the request carries images"
498
- elif not n_explorers:
499
- shape, how = collection.get("shape") or "general", "no explorer seats to fill"
500
- elif collection.get("shape"):
501
- shape, how = collection["shape"], "set on the collection"
502
- else:
503
- shape, how = pick_shape(whole)
504
- seats = [(f"member{i}", "member", alias) for i, alias in enumerate(sitting, 1)]
505
- if n_explorers:
506
- # `with` in the request body is route.py's --with: an explorer seat.
507
- from run import _registered_names
508
- avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
509
- if pictures:
510
- avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
511
- p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
512
- members=sitting, explorers=n_explorers, avoid=avoid)
513
- seats += [(key, label, p[key]["model"]) for key, label in
514
- (("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
515
- if not seats and pictures:
516
- raise ValueError(f"no seat in bench/{name} can see images, so none is asked rather than answer"
517
- " blind: " + ("; ".join(f"{k['alias']} {k['why']}" for k in skipped) or "no members")
518
- + ", and no model that takes images was free for an explorer seat. Add one to the"
519
- " collection, or call one whose together.capabilities on GET /v1/models lists vision.")
520
- if not seats:
521
- raise LookupError(f"collection {name!r} has no members and no explorer could be seated"
522
- + "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
523
- panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
524
- model = bench_collections.PREFIX + name
525
- with ThreadPoolExecutor(max_workers=len(seats)) as pool:
526
- results = list(pool.map(lambda s: ask_seat(s[2], system, prompt, messages if pictures else None), seats))
527
-
528
- task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
529
- LOG_DIR.mkdir(parents=True, exist_ok=True)
530
- record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
531
- "model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
532
- "prompt": prompt, "seats": [], **({"skipped": skipped} if skipped else {})}
533
- out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
534
- f" explorers · explorer shape {shape} ({how})\n"]
535
- if skipped:
536
- out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
537
- for (key, label, alias), r in zip(seats, results):
538
- call = f"{panel_id}.{key}"
539
- record["seats"].append(record_seat(task, call, label, alias, r))
540
- body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
541
- "_empty reply — usually a token budget, not incapacity_")
542
- out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
543
- out.append("---\nRecord a verdict per seat once checked against the code:\n"
544
- "`POST /bench/verdict` on this same server (see `bench.verdict`), or"
545
- " `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
546
- content = "\n".join(out)
547
- # Beyond the OpenAI fields, so a caller reads seats without parsing the
548
- # markdown. Clients that do not know it ignore it.
549
- bench = {"panel": panel_id, "collection": model, "shape": shape, "shape_by": how,
550
- "seats": [{k: s[k] for k in SEAT_KEYS} for s in record["seats"]],
551
- # A path, not a URL: the same server answered, whichever port or
552
- # proxy the caller came through (the dashboard serves these routes too).
553
- "verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
554
- **({"skipped": skipped} if skipped else {})}
555
- if merged:
556
- try:
557
- merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
558
- except Exception as exc: # noqa: BLE001 -- never lose the panel to the merge
559
- merger, note = None, f"the merge failed ({type(exc).__name__}: {exc})"
560
- if merger:
561
- bench["merger"] = {k: merger[k] for k in SEAT_KEYS}
562
- record["seats"].append(merger) # the dashboard shows it, verdict and all
563
- if note:
564
- bench.update(reply="all", note=note)
565
- content = f"_Merged reply unavailable: {note}. Every answer follows._\n\n{content}"
566
- else:
567
- who = ", ".join(f"{s['alias']} {s['status']}" for s in bench["seats"])
568
- bench["reply"] = "merged"
569
- content = (f"{merger['answer']}\n\n---\nMerged by {merger['alias']} (`{merger['call']}`) from:"
570
- f" {who}. Every seat's answer and call id is in the response's `bench` field.")
571
- with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
572
- fh.write(json.dumps(record, ensure_ascii=False) + "\n")
573
- return content, bench
574
-
575
-
576
- def models() -> list[str]:
577
- return [bench_collections.PREFIX + n for n in bench_collections.load()]
578
-
579
-
580
- class Handler(BaseHTTPRequestHandler):
581
- """The gateway's routes. serve.py's Handler subclasses this to answer them
582
- on the dashboard's port too, so the checks use the port actually served."""
583
- who = "-"
584
-
585
- def _json(self, code: int, data: dict, headers: dict | None = None) -> None:
586
- body = json.dumps(data).encode()
587
- self.send_response(code)
588
- self.send_header("Content-Type", "application/json")
589
- self.send_header("Content-Length", str(len(body)))
590
- for k, v in (headers or {}).items():
591
- self.send_header(k, v)
592
- self.end_headers()
593
- self.wfile.write(body)
594
-
595
- def _error(self, code: int, message: str, headers: dict | None = None) -> None:
596
- self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}}, headers=headers)
597
-
598
- def end_headers(self):
599
- # CORS for a browser app on an allowed origin, on the gateway's routes
600
- # only (metrics/guard.py); Vary, so a cache never hands one origin's
601
- # answer to another. A request line too broken to parse has no headers.
602
- headers = getattr(self, "headers", None)
603
- if headers is not None and guard.gateway_route((getattr(self, "path", "") or "").split("?", 1)[0]) \
604
- and (origin := guard.cors_origin(headers, self.server.server_port)):
605
- self.send_header("Access-Control-Allow-Origin", origin)
606
- self.send_header("Vary", "Origin")
607
- super().end_headers()
608
-
609
- def do_OPTIONS(self): # noqa: N802
610
- """A browser's CORS preflight: answered on the gateway's routes, for an
611
- allowed origin only. The dashboard's routes answer none, as before."""
612
- if not guard.gateway_route(self.path.split("?", 1)[0]):
613
- return self.send_error(501, f"Unsupported method ({self.command!r})")
614
- try:
615
- guard.check(self.headers, self.server.server_port, post=False, max_body=0)
616
- except guard.Refused as r:
617
- return self._error(r.status, str(r))
618
- if not guard.cors_origin(self.headers, self.server.server_port):
619
- return self._error(403, f"Origin {self.headers.get('Origin') or '(none)'!r} is not allowed;"
620
- " set BENCH_ALLOWED_ORIGINS or BENCHWARMERS_PUBLIC_URL to add one")
621
- # The headers it asks for as well, when they are plain names: the
622
- # OpenAI JS SDK sends X-Stainless-* on every call, and a preflight that
623
- # refused them would block it. The origin is the boundary, not these.
624
- asked = (self.headers.get("Access-Control-Request-Headers") or "").strip()
625
- self.send_response(204)
626
- self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
627
- self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type"
628
- + (f", {asked}" if HEADER_NAMES.fullmatch(asked) else ""))
629
- self.send_header("Access-Control-Max-Age", "600")
630
- self.end_headers()
631
-
632
- def do_GET(self): # noqa: N802
633
- try:
634
- guard.check(self.headers, self.server.server_port, post=False, max_body=0)
635
- self.who = guard.gateway_caller(self.headers)
636
- except guard.Refused as r:
637
- return self._error(r.status, str(r), r.headers)
638
- if self.path.split("?", 1)[0].rstrip("/") in ("/v1/models", "/models"):
639
- # Beyond the OpenAI fields: what each collection is for, so an
640
- # orchestrator can choose one by task. Clients ignore extra keys.
641
- # Each collection twice: as set, and in the other reply mode, so a
642
- # client that offers only listed models can still pick either.
643
- s, data = bench_collections.settings(), bench_collections.load()
644
- rows = []
645
- for n, c in data.items():
646
- if not guard.may_call(self.who, n): # a key limited to some collections sees those
647
- continue
648
- d = bench_collections.described(n, c, s)
649
- other = "all" if d["reply"] == "merged" else "merged"
650
- rows += [{"id": bench_collections.PREFIX + n, "object": "model", "owned_by": "benchwarmers", **d},
651
- {"id": f"{bench_collections.PREFIX}{n}:{other}", "object": "model",
652
- "owned_by": "benchwarmers", **d, "reply": other}]
653
- self._json(200, {"object": "list", "data": rows})
654
- else:
655
- self._error(404, "GET /v1/models is the only GET here")
656
-
657
- def do_POST(self): # noqa: N802
658
- # Without this any web page could spend model calls, seat ad hoc
659
- # models through `with`, or write verdicts (metrics/guard.py).
660
- try:
661
- length = guard.check(self.headers, self.server.server_port, post=True, max_body=MAX_BODY, cors=True)
662
- self.who = guard.gateway_caller(self.headers)
663
- except guard.Refused as r:
664
- return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers)
665
- try:
666
- body = json.loads(self.rfile.read(length) or b"{}")
667
- except ValueError:
668
- return self._error(400, "body is not JSON")
669
- if not isinstance(body, dict):
670
- return self._error(400, "body must be a JSON object")
671
- path = self.path.split("?", 1)[0].rstrip("/")
672
- if path == "/bench/verdict":
673
- if not isinstance(body.get("useful"), bool):
674
- return self._error(400, '"useful" must be true or false')
675
- lists = {k: body.get(k) or [] for k in ("real", "false")}
676
- if not all(isinstance(v, list) and all(isinstance(s, str) for s in v) for v in lists.values()):
677
- return self._error(400, '"real" and "false" must be lists of one-sentence claims')
678
- try:
679
- e = offload.verdict(str(body.get("call", "")), body["useful"], str(body.get("note") or ""),
680
- lists["real"], lists["false"])
681
- except KeyError as exc:
682
- return self._error(404, exc.args[0])
683
- return self._json(200, {"ok": True, "recorded": e})
684
- if path not in ("/v1/chat/completions", "/chat/completions"):
685
- return self._error(404, "POST /v1/chat/completions or /bench/verdict")
686
- model = body.get("model") or ""
687
- if not isinstance(model, str):
688
- return self._error(400, '"model" must be a collection name such as bench/core')
689
- model, suffix = bench_collections.split_reply(model)
690
- name = bench_collections.resolve(model)
691
- if name is None:
692
- return self._error(400 if not model else 404, bench_collections.choose_message(model))
693
- model = bench_collections.PREFIX + name
694
- messages, nominated, opts = body.get("messages"), body.get("with") or [], body.get("bench") or {}
695
- if not (isinstance(messages, list) and all(isinstance(m, dict) for m in messages)):
696
- return self._error(400, '"messages" must be a list of message objects')
697
- if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
698
- return self._error(400, '"with" must be a list of model aliases')
699
- if not isinstance(opts, dict) or opts.get("reply", "all") not in bench_collections.REPLIES:
700
- return self._error(400, '"bench" must be an object whose "reply" is "all" or "merged"')
701
- if not guard.may_call(self.who, name):
702
- allowed = ", ".join(bench_collections.PREFIX + c for c in guard.scope(self.who) or []) or "none"
703
- return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}")
704
- try:
705
- guard.spend(self.who) # counted once it names a collection it may call
706
- except guard.Refused as r:
707
- return self._error(r.status, str(r), r.headers)
708
- try:
709
- # The body is the most specific, then the suffix, then the collection.
710
- content, bench = run_collection(name, messages, nominated, opts.get("reply") or suffix, self.who)
711
- except TooLarge as exc:
712
- return self._error(413, str(exc))
713
- except ValueError as exc:
714
- return self._error(400, str(exc))
715
- except LookupError as exc:
716
- return self._error(503, str(exc))
717
- reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
718
- if not body.get("stream"):
719
- return self._json(200, {
720
- "id": reply_id, "object": "chat.completion", "created": created, "model": model,
721
- "choices": [{"index": 0, "finish_reason": "stop",
722
- "message": {"role": "assistant", "content": content}}], "bench": bench})
723
- # A panel finishes all at once, so a streaming client gets it as one
724
- # chunk, merged or not. ponytail: nothing is sent until the panel and
725
- # the merge are done, so a client or proxy with a shorter idle timeout
726
- # drops it. Keep-alives mean sending the 200 before the panel runs, so
727
- # the 413/503 refusals move into the stream; streaming the merge as it
728
- # is written needs a streaming client.chat (RELEASE-PLAN acceptance 11).
729
- self.send_response(200)
730
- self.send_header("Content-Type", "text/event-stream")
731
- self.end_headers()
732
- for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
733
- chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
734
- "model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
735
- **({"bench": bench} if finish is None else {})}
736
- self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
737
- self.wfile.write(b"data: [DONE]\n\n")
738
-
739
- def log_request(self, code="-", size="-"): # route, status, who; never a query or a key
740
- guard.access(self, code)
741
-
742
- def log_message(self, fmt, *args): # quieter than the default
743
- sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
744
-
745
-
746
- def main() -> None:
747
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
748
- address = guard.bind()
749
- guard.startup(address)
750
- print(f"bench gateway on http://{address}:{PORT}/v1 — models: {', '.join(models())}")
751
- ThreadingHTTPServer((address, PORT), Handler).serve_forever()
752
-
753
-
754
- if __name__ == "__main__":
755
- 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 a COLLECTION by name — `bench/core`, `bench/reviewers` — and the prompt
6
+ goes in parallel to every member of it plus explorer seats `route.py` fills by
7
+ staleness, so the roster keeps getting evidence even when the same collection
8
+ is called every day:
9
+
10
+ explorers = min(max_explorers, max_calls - members) # defaults 2 and 5
11
+
12
+ Collections and those limits live in `config/collections.json` and
13
+ `config/gateway.json` (see `metrics/bench_collections.py`), edited from the
14
+ dashboard. There is no default: a request that names no collection, or one
15
+ that does not exist, is refused with every collection's description and tags,
16
+ so the caller can choose — `GET /v1/models` carries the same.
17
+
18
+ The reply is every answer, each labelled by seat and model with a call id,
19
+ including seats that failed or came back empty: who answered is evidence about
20
+ the roster, and a summary naming only the good answer hides it.
21
+
22
+ Unless the reply is `merged` (a collection's `reply`, or per request a
23
+ `:merged` suffix on the model or `"bench": {"reply": "merged"}`; `:all` the
24
+ other way): then, after the panel, one more model writes a single answer
25
+ saying where the seats agreed, where they disagreed and who said what, for a
26
+ client that can show only one. It has its own call id, so a verdict can judge
27
+ the merge, and the `bench` field still carries every seat. If no merger has
28
+ the context for the merge, or it fails, the reply is every answer with a
29
+ one-line note: the panel the caller waited for is never lost.
30
+
31
+ A collection's `shape` (review, design, …) decides which models may fill the
32
+ explorer seats. Left empty, a decision model labels the prompt when one that
33
+ takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
34
+ the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
35
+ model in an explorer chair, as `route.py --with` does.
36
+
37
+ A retired model (`config/retired.json`) is never called: retiring takes it out
38
+ of every collection, and one a collection still names — as a member, `with`
39
+ or its merger — is skipped and named in `bench.skipped`, its seat going to an
40
+ explorer and the merge to a merger chosen by verdicts.
41
+
42
+ THE LOOP
43
+ --------
44
+ Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
45
+ moment it answers, so the explorer seats rotate even when nobody judges. The
46
+ caller then checks the answers against the real code — agreement between seats
47
+ is not a verdict, they fail together — and records one per call id:
48
+
49
+ python metrics/offload.py verdict <call id> --useful --note "..." \\
50
+ --real "<a finding it got right>" --false "<a claim it made that is untrue>"
51
+ POST /bench/verdict {"call": "<call id>", "useful": true, "note": "...",
52
+ "real": ["..."], "false": ["..."]}
53
+
54
+ The real/false claims are what `suite/mine.py` turns into suite tasks.
55
+
56
+ The verdict replaces the unjudged entry, and route.py's evidence — which
57
+ explorers are due, which models earn a place in a collection — reads it.
58
+
59
+ IMAGES
60
+ ------
61
+ A request whose messages carry `image_url` parts (http(s) or data: URLs) goes
62
+ to each seat with its messages as sent, through the built-in client whatever
63
+ BENCH_CLIENT says (`llm -m` takes a prompt). Only models the catalog says take
64
+ images sit (`cannot_see`): a member that cannot, or that the catalog knows
65
+ nothing about, is skipped and named in `bench.skipped` with why; explorers
66
+ are drawn from models that can, under the shape `vision` unless the
67
+ collection sets one; with no seat that can see, the request is refused (400)
68
+ rather than answered blind. Each image counts IMAGE_TOKENS in the size check.
69
+ Wherever the request is written down (the call log, offloads, learned
70
+ limits, error text, the dashboard) an image is a placeholder, never its
71
+ bytes. The merger gets the request as text with those placeholders, plus
72
+ the seats' answers: its job is to reconcile what the seats said, not to look
73
+ again, so any model can merge and a disagreement about the picture is
74
+ reported rather than settled.
75
+
76
+ WHAT IT IS NOT
77
+ --------------
78
+ A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
79
+ exists only for the multi-model work. Seats are called with `llm -m`, exactly
80
+ as offloads always were, or the built-in client (BENCH_CLIENT), so endpoints,
81
+ headers and per-model options all come from the same registry. Full prompts
82
+ and answers go to `runs/gateway/`, gitignored, with the name of the gateway
83
+ key that asked. Bound to 127.0.0.1, like the dashboard (BENCH_BIND and
84
+ BENCH_GATEWAY_PORT change that; beyond this machine it asks for a gateway key,
85
+ metrics/guard.py, which also covers keys on this machine, scopes, caps and
86
+ CORS). The dashboard answers these same routes on its own port.
87
+ """
88
+
89
+ from __future__ import annotations
90
+
91
+ import datetime as dt
92
+ import json
93
+ import os
94
+ import pathlib
95
+ import re
96
+ import secrets
97
+ import subprocess
98
+ import sys
99
+ import time
100
+ import urllib.parse
101
+ from concurrent.futures import ThreadPoolExecutor
102
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
103
+
104
+ ROOT = pathlib.Path(__file__).resolve().parent
105
+ sys.path.insert(0, str(ROOT / "metrics"))
106
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
107
+ PORT = int(os.environ.get("BENCH_GATEWAY_PORT") or 8123)
108
+ LOG_DIR = DATA / "runs" / "gateway"
109
+ # The suite's own timeout. Above the slowest real answer seen (383 s); at 900
110
+ # one stuck explorer held a whole request for fifteen minutes.
111
+ SEAT_TIMEOUT = 420
112
+ sys.path.insert(0, str(ROOT / "metrics"))
113
+ sys.path.insert(0, str(ROOT / "suite"))
114
+ import bench_collections # noqa: E402
115
+ import client # noqa: E402
116
+ import decide # noqa: E402
117
+ import guard # noqa: E402
118
+ import offload # noqa: E402
119
+ import route # noqa: E402
120
+
121
+ # What the shape picker is asked to choose between. Each option carries a
122
+ # description because bare names mislead it: Mercury Decide called "list every
123
+ # place serve.py writes a file" `code` at 0.93 when given names alone.
124
+ SHAPES = {
125
+ "general": "A general question or task that fits none of the other kinds.",
126
+ "design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
127
+ "prose": "Writing or editing documentation or other text.",
128
+ "review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
129
+ "code": "Writing new code or changing existing code.",
130
+ "enumeration": "Listing every place something happens in given code or files.",
131
+ "quick": "A short, bounded task: classify, extract or summarise a small input.",
132
+ }
133
+ CONFIDENT = 0.6
134
+ LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
135
+ # Bytes. A million-token prompt is about 4 MB, and a photo sent as a data URL
136
+ # is 4/3 of its file. 20 MB is the tightest whole-request limit among the big
137
+ # providers (Gemini's for inline data), so what passes here they take too.
138
+ MAX_BODY = 20_000_000
139
+ TOO_BIG = (" Send images as http(s) URLs, or smaller ones: models scale a photo down to about"
140
+ " 1,500-2,000 pixels on its long side anyway.")
141
+ IMAGE_TOKENS = 2_500 # per image, in the size check (estimate_tokens says why)
142
+ IMAGE_URL = re.compile(r"(https?://[^\s/?#]+|data:image/[\w.+-]+;base64,)", re.I)
143
+ DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
144
+ URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
145
+ BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
146
+ HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
147
+ SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
148
+ # A merge that reads as consensus hides the dissent the bench runs on, so the
149
+ # merger is told to keep who said what.
150
+ MERGE_SYSTEM = ("Several models answered the same request. Write one answer to it from theirs: say where"
151
+ " they agreed, where they disagreed, and who said what, naming each model as labelled."
152
+ " Keep every dissent, and do not present a claim as settled because several made it.")
153
+
154
+
155
+ def flatten(messages: list[dict]) -> tuple[str, str]:
156
+ """(system, prompt) for `llm`. A panel question is one prompt; a longer
157
+ conversation is kept as labelled turns rather than silently truncated.
158
+ An image is its placeholder: this text is what gets written down."""
159
+ def text(m):
160
+ c = m.get("content") or ""
161
+ return c if isinstance(c, str) else "\n".join(
162
+ placeholder(_url(p)) if p.get("type") == "image_url" else str(p.get("text") or "")
163
+ for p in c if isinstance(p, dict))
164
+ # Newer OpenAI SDKs send "developer" where older ones send "system".
165
+ lead = ("system", "developer")
166
+ system = "\n\n".join(text(m) for m in messages if m.get("role") in lead)
167
+ turns = [m for m in messages if m.get("role") not in lead]
168
+ if len(turns) == 1:
169
+ return system, text(turns[0])
170
+ return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
171
+
172
+
173
+ def _url(part: dict) -> str:
174
+ """An image part's URL: {"image_url": {"url": ...}} as OpenAI sends it, or a bare string."""
175
+ u = part.get("image_url")
176
+ u = u.get("url") if isinstance(u, dict) else u
177
+ return u if isinstance(u, str) else ""
178
+
179
+
180
+ def images(messages: list[dict]) -> list[str]:
181
+ """Every image part's URL, in order. ValueError for one no seat could be
182
+ sent: the gateway never fetches an image, the upstream does, so only
183
+ http(s) URLs and inline base64 images pass; and an image in another API's
184
+ shape is refused rather than dropped, which would have a seat answer blind."""
185
+ urls = []
186
+ for m in messages:
187
+ parts = m.get("content")
188
+ for p in parts if isinstance(parts, list) else ():
189
+ kind = p.get("type") if isinstance(p, dict) else None
190
+ if kind in ("image", "input_image"):
191
+ raise ValueError(f'a "{kind}" part is another API\'s shape; send each image as'
192
+ ' {"type": "image_url", "image_url": {"url": "<http(s) or data: URL>"}}')
193
+ if kind == "image_url":
194
+ url = _url(p)
195
+ if not IMAGE_URL.match(url):
196
+ raise ValueError("an image_url must be an http(s) URL or a data:image/...;base64, URL,"
197
+ f" not {placeholder(url) if url else 'empty'}")
198
+ urls.append(url)
199
+ return urls
200
+
201
+
202
+ def placeholder(url: str) -> str:
203
+ """How an image is written down (the call log, offloads, the dashboard,
204
+ error text, the merger's copy of the request), never its bytes: a data
205
+ URL by its size, an http URL without its query (a signed URL's signature
206
+ is a credential) or a user:password."""
207
+ if url[:5].lower() == "data:":
208
+ return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
209
+ u = urllib.parse.urlsplit(url)
210
+ return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
211
+
212
+
213
+ def redact(text: str) -> str:
214
+ """`text` with each inline image as its placeholder, every http(s) URL
215
+ without its query or user:password, and any other long base64 run as its
216
+ size: an upstream may echo the request in an error, in whatever form, and
217
+ errors are logged, shown and learned from."""
218
+ def bare(m):
219
+ try:
220
+ u = urllib.parse.urlsplit(m.group())
221
+ except ValueError: # an unparseable netloc, such as "[::1"
222
+ return "[url]"
223
+ return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
224
+ text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
225
+ return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
226
+
227
+
228
+ def cannot_see(alias: str) -> str:
229
+ """Why an image cannot be put to `alias`, or "" when it can. The catalog
230
+ decides (metrics/catalog.py: the model's input types, your Vision / OCR
231
+ override in its model popup, a capability seen working). A model the
232
+ catalog knows nothing about does not sit either: one that cannot see
233
+ answers anyway, describing a photo it never got, and the override is one
234
+ click."""
235
+ import catalog
236
+ import endpoints
237
+ if endpoints.entry_of(alias).get("api") == "responses":
238
+ return "reached through the Responses API, which the gateway sends no images to yet"
239
+ rec = catalog.info(alias)
240
+ if rec.get("vision"):
241
+ return ""
242
+ if rec.get("vision") is None:
243
+ return "the catalog does not say whether it takes images; tick Vision / OCR in its model popup if it does"
244
+ return f"does not take images (says {rec['sources'].get('vision', 'the catalog')})"
245
+
246
+
247
+ def pick_shape(prompt: str) -> tuple[str, str]:
248
+ """(shape, how it was chosen). Falls back to general on any doubt."""
249
+ try:
250
+ held = set(json.loads((DATA / "config" / "model-roles.json").read_text(encoding="utf-8"))
251
+ .get("ad_hoc", []))
252
+ except (OSError, ValueError, AttributeError):
253
+ return "general", "config/model-roles.json unreadable"
254
+ picker = next((a for a, d in decide.load().items()
255
+ if "choice" in d["question_types"] and a not in held), None)
256
+ if not picker:
257
+ return "general", "no decision model onboarded"
258
+ question = {"shape": {"type": "choice",
259
+ "instructions": "What kind of task does this prompt ask for?",
260
+ "criteria": SHAPES}}
261
+ try:
262
+ answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
263
+ except Exception as exc: # noqa: BLE001
264
+ return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
265
+ conf = answer.get("confidence", 0)
266
+ if answer.get("choice") not in SHAPES or conf < CONFIDENT:
267
+ return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
268
+ return answer["choice"], f"{picker}, {conf:.2f}"
269
+
270
+
271
+ def retired() -> dict[str, dict]:
272
+ """config/retired.json: alias -> its record (when, why). Retiring a model
273
+ takes it out of every collection (serve.remove_model); this is for a
274
+ collection that still names one -- a hand edit, or one saved before that
275
+ -- so the seat is skipped with a reason instead of failing on the call."""
276
+ try:
277
+ data = json.loads((DATA / "config" / "retired.json").read_text(encoding="utf-8"))
278
+ except (OSError, ValueError):
279
+ return {}
280
+ return data if isinstance(data, dict) else {}
281
+
282
+
283
+ def retired_why(entry: dict) -> str:
284
+ when = str(entry.get("at", ""))[:10] if isinstance(entry, dict) else ""
285
+ return (f"retired from the bench{' on ' + when if when else ''}; take it out of the collection"
286
+ " (Restore in its model popup brings it back)")
287
+
288
+
289
+ def retiring() -> set[str]:
290
+ """Aliases not to spend an explorer seat on: those on a retiring alert
291
+ (failing their probes; 2026-10-16 both explorers of a bench/core call were
292
+ and both failed), and those cooling down after a rate limit, until the
293
+ wait the provider stated, or 30 minutes (metrics/learn.py)."""
294
+ out: set[str] = set()
295
+ try:
296
+ import alerts
297
+ out |= {a.get("alias", "") for a in alerts.active().values() if a.get("kind") == "retiring"}
298
+ except Exception: # noqa: BLE001
299
+ pass
300
+ try:
301
+ import learn
302
+ out |= set(learn.cooldowns())
303
+ except Exception: # noqa: BLE001
304
+ pass
305
+ return out
306
+
307
+
308
+ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None = None) -> dict:
309
+ """One seat's answer. `messages`: the request as sent, when it carries
310
+ images; it goes through the built-in client whatever BENCH_CLIENT says,
311
+ since `llm -m` takes a prompt, not inline images."""
312
+ cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
313
+ started = time.monotonic()
314
+ if messages is not None or client.backend(alias) == "builtin":
315
+ # No subprocess, and no shared llm log for parallel seats to lock.
316
+ res = client.chat(alias, prompt, system, SEAT_TIMEOUT, messages=messages)
317
+ answer = res["text"]
318
+ error = res["error"] or (f"no answer in {SEAT_TIMEOUT}s" if res["timed_out"] else "")
319
+ else:
320
+ answer, error = _ask_llm(cmd, prompt)
321
+ # A quota or credit notice delivered as the reply's text, with a success
322
+ # exit, is not an answer (suite/run.py PROVIDER_NOTICES).
323
+ from run import provider_notice
324
+ if not error and (notice := provider_notice(answer)):
325
+ error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
326
+ if messages is not None:
327
+ # Before the error is cut, logged or learned from. ponytail: the answer
328
+ # is not redacted; a seat gets an image as pixels, not as its URL or bytes.
329
+ error = redact(error)
330
+ if error:
331
+ # A stated context window, rate limit or wait is kept (metrics/learn.py);
332
+ # a rate limit also cools the model, so explorer seats skip it.
333
+ try:
334
+ import learn
335
+ learn.record(alias, error, "gateway")
336
+ except Exception: # noqa: BLE001
337
+ pass
338
+ status = "error" if error else ("empty" if not answer else "ok")
339
+ return {"status": status, "answer": answer, "error": error[-500:],
340
+ "seconds": round(time.monotonic() - started, 1)}
341
+
342
+
343
+ def _ask_llm(cmd: list[str], prompt: str) -> tuple[str, str]:
344
+ """(answer, error) from the `llm` CLI."""
345
+ try:
346
+ done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
347
+ encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
348
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"})
349
+ answer = done.stdout.strip()
350
+ # A non-zero exit is an error even when stderr is silent, unless it is
351
+ # only llm failing to log an answer it printed (client.py).
352
+ failed = done.returncode and not client.llm_log_failed_only(done.stdout, done.stderr)
353
+ error = (done.stderr.strip() or f"llm exited {done.returncode}") if failed else ""
354
+ except subprocess.TimeoutExpired:
355
+ answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
356
+ except Exception as exc: # noqa: BLE001
357
+ # One seat failing to start must not lose the other seats' records.
358
+ answer, error = "", f"{type(exc).__name__}: {exc}"
359
+ return answer, error
360
+
361
+
362
+ class TooLarge(ValueError):
363
+ """The prompt does not fit a member's context window (HTTP 413)."""
364
+
365
+
366
+ def estimate_tokens(text: str) -> int:
367
+ """Characters / 4 plus 10%: tokenizers differ by family, so this errs high.
368
+ The windows it is compared with are corrected by learned 'maximum context
369
+ length' errors (metrics/learn.py). An image adds IMAGE_TOKENS whatever its
370
+ size, which errs high too: OpenAI's, Anthropic's and Gemini's models spend
371
+ up to about 1,600 on one at their usual detail. Qwen and Llama vision
372
+ models can spend more on a very large image; a learned error corrects that."""
373
+ return int(len(text) / 4 * 1.1)
374
+
375
+
376
+ def too_small(needed: int, aliases) -> dict[str, int]:
377
+ """Aliases whose KNOWN context window is under `needed`, with the window."""
378
+ import catalog
379
+ out = {}
380
+ for a in aliases:
381
+ cw = catalog.info(a).get("context_window")
382
+ if cw and cw < needed:
383
+ out[a] = cw
384
+ return out
385
+
386
+
387
+ def record_seat(task: str, call: str, label: str, alias: str, r: dict) -> dict:
388
+ """Log one answer as an unjudged offload, the moment it is in, so explorer
389
+ rotation counts it even if nobody judges; returns the seat as logged."""
390
+ offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
391
+ + (f": {r['error'][:120]}" if r["error"] else ""), call)
392
+ return {"call": call, "seat": label, "alias": alias, **r}
393
+
394
+
395
+ def merger_candidates(collection: dict, members: list[str], shape: str, large: bool):
396
+ """Who may merge, best first. The collection's `merger` alone when set;
397
+ else the members with verdicts on this shape, most useful first, then the
398
+ router's primary, then the other members (a fresh install has neither
399
+ verdicts nor suite scores). A generator, so the router is asked only if
400
+ no member with a record fits."""
401
+ if collection.get("merger"):
402
+ yield collection["merger"]
403
+ return
404
+ record: dict[str, list[int]] = {}
405
+ for e in offload.entries():
406
+ # The shape ends each seat's task as run_collection writes it: "(bench/<name>, <shape>)".
407
+ if e["model"] in members and e.get("useful") is not None and e.get("task", "").endswith(f", {shape})"):
408
+ r = record.setdefault(e["model"], [0, 0])
409
+ r[0], r[1] = r[0] + bool(e["useful"]), r[1] + 1
410
+ # Useful verdicts counted, not a ratio, as route.py counts findings: one
411
+ # lucky verdict must not outrank a long good record.
412
+ yield from sorted(record, key=lambda a: (-record[a][0], -record[a][0] / record[a][1]))
413
+ try:
414
+ primary = route.panel(shape, large=large)["primary"]
415
+ except Exception: # noqa: BLE001 -- the members can still merge
416
+ primary = None
417
+ if primary:
418
+ yield primary["model"]
419
+ yield from members
420
+
421
+
422
+ def merge(collection: dict, members: list[str], shape: str, whole: str, seats: list[dict],
423
+ task: str, panel_id: str) -> tuple[dict | None, str]:
424
+ """The merger's seat, and why the reply falls back to every answer ("" if
425
+ it does not). The merger reads the request and every answer, so it gets
426
+ the members' size check; a failed merger is still logged like a seat."""
427
+ answered = [s for s in seats if s["status"] == "ok"]
428
+ if not answered:
429
+ return None, "no seat answered"
430
+ text = f"THE REQUEST\n\n{whole}\n\nTHE ANSWERS\n\n" + "\n\n".join(
431
+ f"## {s['seat']} — {s['alias']}\n\n{s['answer']}" for s in answered)
432
+ needed = estimate_tokens(f"{MERGE_SYSTEM}\n\n{text}")
433
+ alias = next((a for a in merger_candidates(collection, members, shape, len(text) > LARGE)
434
+ if not too_small(needed, [a])), None)
435
+ if alias is None:
436
+ return None, f"no merger has the context window for the merge (about {needed:,} tokens)"
437
+ seat = record_seat(task, f"{panel_id}.merger", "merger", alias, ask_seat(alias, MERGE_SYSTEM, text))
438
+ if seat["status"] != "ok":
439
+ return seat, f"the merger {alias} came back {seat['status']}" + (f": {seat['error'][:120]}" if seat["error"] else "")
440
+ return seat, ""
441
+
442
+
443
+ def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
444
+ reply: str = "", who: str = "local") -> tuple[str, dict]:
445
+ """The panel's reply as markdown, and the same seats as data for the
446
+ response's `bench` field. `reply`: the request's mode, "" for the
447
+ collection's own. `who`: the gateway key that asked, for the call log."""
448
+ pictures = images(messages)
449
+ system, prompt = flatten(messages)
450
+ if not prompt.strip():
451
+ raise ValueError("no user message to put to the collection")
452
+ collection = bench_collections.load()[name]
453
+ s = bench_collections.settings()
454
+ raw = collection.get("members") or []
455
+ # The editor dedupes and caps; a hand-edited file may not have.
456
+ members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
457
+ # A RETIRED MODEL IS NOT CALLED: it is no longer registered, so the call
458
+ # could only fail ("Unknown model"). Skipped, and named, before the cap so
459
+ # it frees its seat for the next member or an explorer. Retiring takes it
460
+ # out of the collection, so this is a collection that still names one.
461
+ gone = retired()
462
+ skipped = [{"seat": "member", "alias": a, "why": retired_why(gone[a])} for a in members if a in gone]
463
+ members = [a for a in members if a not in gone][:s["max_calls"]]
464
+ # A member named in `with` is already seated; it must not use up the slot.
465
+ nominated = [a for a in dict.fromkeys(nominated or []) if a not in members]
466
+ skipped += [{"seat": "with", "alias": a, "why": retired_why(gone[a])} for a in nominated
467
+ if a in gone and a not in {k["alias"] for k in skipped}]
468
+ nominated = [a for a in nominated if a not in gone]
469
+ merged = (reply or collection.get("reply") or "all") == "merged"
470
+ if collection.get("merger") in gone:
471
+ if merged:
472
+ skipped.append({"seat": "merger", "alias": collection["merger"],
473
+ "why": retired_why(gone[collection["merger"]]) + "; the merger is chosen by verdicts"})
474
+ collection = {**collection, "merger": ""}
475
+ n_explorers = bench_collections.explorers_for(len(members), s)
476
+ # AN IMAGE GOES ONLY TO A MODEL THAT CAN SEE IT: one that cannot would
477
+ # answer anyway, about a picture it never got. Skipped, and named.
478
+ skipped += [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
479
+ for a in group if pictures and (why := cannot_see(a))]
480
+ unseen = {k["alias"] for k in skipped}
481
+ sitting = [a for a in members if a not in unseen]
482
+ nominated = [a for a in nominated if a not in unseen]
483
+ # The system text goes to every seat too, so it counts for shape and size.
484
+ whole = f"{system}\n\n{prompt}".strip()
485
+ # REFUSE WHAT A MEMBER CANNOT READ, so the caller can trim the prompt or
486
+ # pick another collection instead of collecting a seat's context error.
487
+ needed = estimate_tokens(whole) + IMAGE_TOKENS * len(pictures)
488
+ small = too_small(needed, sitting)
489
+ if small:
490
+ windows = ", ".join(f"{a} {cw:,}" for a, cw in sorted(small.items(), key=lambda kv: kv[1]))
491
+ counted = f" (each image counted as {IMAGE_TOKENS:,})" if pictures else ""
492
+ raise TooLarge(f"prompt is about {needed:,} tokens{counted}; too large for {windows} (context"
493
+ f" windows) in bench/{name}. Trim it under {min(small.values()):,} tokens,"
494
+ " or choose a collection whose members have larger windows"
495
+ " (GET /v1/models lists each collection's context_window).")
496
+ if pictures and not collection.get("shape"):
497
+ shape, how = "vision", "the request carries images"
498
+ elif not n_explorers:
499
+ shape, how = collection.get("shape") or "general", "no explorer seats to fill"
500
+ elif collection.get("shape"):
501
+ shape, how = collection["shape"], "set on the collection"
502
+ else:
503
+ shape, how = pick_shape(whole)
504
+ seats = [(f"member{i}", "member", alias) for i, alias in enumerate(sitting, 1)]
505
+ if n_explorers:
506
+ # `with` in the request body is route.py's --with: an explorer seat.
507
+ from run import _registered_names
508
+ avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
509
+ if pictures:
510
+ avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
511
+ p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
512
+ members=sitting, explorers=n_explorers, avoid=avoid)
513
+ seats += [(key, label, p[key]["model"]) for key, label in
514
+ (("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
515
+ if not seats and pictures:
516
+ raise ValueError(f"no seat in bench/{name} can see images, so none is asked rather than answer"
517
+ " blind: " + ("; ".join(f"{k['alias']} {k['why']}" for k in skipped) or "no members")
518
+ + ", and no model that takes images was free for an explorer seat. Add one to the"
519
+ " collection, or call one whose together.capabilities on GET /v1/models lists vision.")
520
+ if not seats:
521
+ raise LookupError(f"collection {name!r} has no members and no explorer could be seated"
522
+ + "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
523
+ panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
524
+ model = bench_collections.PREFIX + name
525
+ with ThreadPoolExecutor(max_workers=len(seats)) as pool:
526
+ results = list(pool.map(lambda s: ask_seat(s[2], system, prompt, messages if pictures else None), seats))
527
+
528
+ task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
529
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
530
+ record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
531
+ "model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
532
+ "prompt": prompt, "seats": [], **({"skipped": skipped} if skipped else {})}
533
+ out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
534
+ f" explorers · explorer shape {shape} ({how})\n"]
535
+ if skipped:
536
+ out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
537
+ for (key, label, alias), r in zip(seats, results):
538
+ call = f"{panel_id}.{key}"
539
+ record["seats"].append(record_seat(task, call, label, alias, r))
540
+ body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
541
+ "_empty reply — usually a token budget, not incapacity_")
542
+ out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
543
+ out.append("---\nRecord a verdict per seat once checked against the code:\n"
544
+ "`POST /bench/verdict` on this same server (see `bench.verdict`), or"
545
+ " `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
546
+ content = "\n".join(out)
547
+ # Beyond the OpenAI fields, so a caller reads seats without parsing the
548
+ # markdown. Clients that do not know it ignore it.
549
+ bench = {"panel": panel_id, "collection": model, "shape": shape, "shape_by": how,
550
+ "seats": [{k: s[k] for k in SEAT_KEYS} for s in record["seats"]],
551
+ # A path, not a URL: the same server answered, whichever port or
552
+ # proxy the caller came through (the dashboard serves these routes too).
553
+ "verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
554
+ **({"skipped": skipped} if skipped else {})}
555
+ if merged:
556
+ try:
557
+ merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
558
+ except Exception as exc: # noqa: BLE001 -- never lose the panel to the merge
559
+ merger, note = None, f"the merge failed ({type(exc).__name__}: {exc})"
560
+ if merger:
561
+ bench["merger"] = {k: merger[k] for k in SEAT_KEYS}
562
+ record["seats"].append(merger) # the dashboard shows it, verdict and all
563
+ if note:
564
+ bench.update(reply="all", note=note)
565
+ content = f"_Merged reply unavailable: {note}. Every answer follows._\n\n{content}"
566
+ else:
567
+ who = ", ".join(f"{s['alias']} {s['status']}" for s in bench["seats"])
568
+ bench["reply"] = "merged"
569
+ content = (f"{merger['answer']}\n\n---\nMerged by {merger['alias']} (`{merger['call']}`) from:"
570
+ f" {who}. Every seat's answer and call id is in the response's `bench` field.")
571
+ with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
572
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
573
+ return content, bench
574
+
575
+
576
+ def models() -> list[str]:
577
+ return [bench_collections.PREFIX + n for n in bench_collections.load()]
578
+
579
+
580
+ class Handler(BaseHTTPRequestHandler):
581
+ """The gateway's routes. serve.py's Handler subclasses this to answer them
582
+ on the dashboard's port too, so the checks use the port actually served."""
583
+ who = "-"
584
+
585
+ def _json(self, code: int, data: dict, headers: dict | None = None) -> None:
586
+ body = json.dumps(data).encode()
587
+ self.send_response(code)
588
+ self.send_header("Content-Type", "application/json")
589
+ self.send_header("Content-Length", str(len(body)))
590
+ for k, v in (headers or {}).items():
591
+ self.send_header(k, v)
592
+ self.end_headers()
593
+ self.wfile.write(body)
594
+
595
+ def _error(self, code: int, message: str, headers: dict | None = None) -> None:
596
+ self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}}, headers=headers)
597
+
598
+ def end_headers(self):
599
+ # CORS for a browser app on an allowed origin, on the gateway's routes
600
+ # only (metrics/guard.py); Vary, so a cache never hands one origin's
601
+ # answer to another. A request line too broken to parse has no headers.
602
+ headers = getattr(self, "headers", None)
603
+ if headers is not None and guard.gateway_route((getattr(self, "path", "") or "").split("?", 1)[0]) \
604
+ and (origin := guard.cors_origin(headers, self.server.server_port)):
605
+ self.send_header("Access-Control-Allow-Origin", origin)
606
+ self.send_header("Vary", "Origin")
607
+ super().end_headers()
608
+
609
+ def do_OPTIONS(self): # noqa: N802
610
+ """A browser's CORS preflight: answered on the gateway's routes, for an
611
+ allowed origin only. The dashboard's routes answer none, as before."""
612
+ if not guard.gateway_route(self.path.split("?", 1)[0]):
613
+ return self.send_error(501, f"Unsupported method ({self.command!r})")
614
+ try:
615
+ guard.check(self.headers, self.server.server_port, post=False, max_body=0)
616
+ except guard.Refused as r:
617
+ return self._error(r.status, str(r))
618
+ if not guard.cors_origin(self.headers, self.server.server_port):
619
+ return self._error(403, f"Origin {self.headers.get('Origin') or '(none)'!r} is not allowed;"
620
+ " set BENCH_ALLOWED_ORIGINS or BENCHWARMERS_PUBLIC_URL to add one")
621
+ # The headers it asks for as well, when they are plain names: the
622
+ # OpenAI JS SDK sends X-Stainless-* on every call, and a preflight that
623
+ # refused them would block it. The origin is the boundary, not these.
624
+ asked = (self.headers.get("Access-Control-Request-Headers") or "").strip()
625
+ self.send_response(204)
626
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
627
+ self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type"
628
+ + (f", {asked}" if HEADER_NAMES.fullmatch(asked) else ""))
629
+ self.send_header("Access-Control-Max-Age", "600")
630
+ self.end_headers()
631
+
632
+ def do_GET(self): # noqa: N802
633
+ try:
634
+ guard.check(self.headers, self.server.server_port, post=False, max_body=0)
635
+ self.who = guard.gateway_caller(self.headers)
636
+ except guard.Refused as r:
637
+ return self._error(r.status, str(r), r.headers)
638
+ if self.path.split("?", 1)[0].rstrip("/") in ("/v1/models", "/models"):
639
+ # Beyond the OpenAI fields: what each collection is for, so an
640
+ # orchestrator can choose one by task. Clients ignore extra keys.
641
+ # Each collection twice: as set, and in the other reply mode, so a
642
+ # client that offers only listed models can still pick either.
643
+ s, data = bench_collections.settings(), bench_collections.load()
644
+ rows = []
645
+ for n, c in data.items():
646
+ if not guard.may_call(self.who, n): # a key limited to some collections sees those
647
+ continue
648
+ d = bench_collections.described(n, c, s)
649
+ other = "all" if d["reply"] == "merged" else "merged"
650
+ rows += [{"id": bench_collections.PREFIX + n, "object": "model", "owned_by": "benchwarmers", **d},
651
+ {"id": f"{bench_collections.PREFIX}{n}:{other}", "object": "model",
652
+ "owned_by": "benchwarmers", **d, "reply": other}]
653
+ self._json(200, {"object": "list", "data": rows})
654
+ else:
655
+ self._error(404, "GET /v1/models is the only GET here")
656
+
657
+ def do_POST(self): # noqa: N802
658
+ # Without this any web page could spend model calls, seat ad hoc
659
+ # models through `with`, or write verdicts (metrics/guard.py).
660
+ try:
661
+ length = guard.check(self.headers, self.server.server_port, post=True, max_body=MAX_BODY, cors=True)
662
+ self.who = guard.gateway_caller(self.headers)
663
+ except guard.Refused as r:
664
+ return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers)
665
+ try:
666
+ body = json.loads(self.rfile.read(length) or b"{}")
667
+ except ValueError:
668
+ return self._error(400, "body is not JSON")
669
+ if not isinstance(body, dict):
670
+ return self._error(400, "body must be a JSON object")
671
+ path = self.path.split("?", 1)[0].rstrip("/")
672
+ if path == "/bench/verdict":
673
+ if not isinstance(body.get("useful"), bool):
674
+ return self._error(400, '"useful" must be true or false')
675
+ lists = {k: body.get(k) or [] for k in ("real", "false")}
676
+ if not all(isinstance(v, list) and all(isinstance(s, str) for s in v) for v in lists.values()):
677
+ return self._error(400, '"real" and "false" must be lists of one-sentence claims')
678
+ try:
679
+ e = offload.verdict(str(body.get("call", "")), body["useful"], str(body.get("note") or ""),
680
+ lists["real"], lists["false"])
681
+ except KeyError as exc:
682
+ return self._error(404, exc.args[0])
683
+ return self._json(200, {"ok": True, "recorded": e})
684
+ if path not in ("/v1/chat/completions", "/chat/completions"):
685
+ return self._error(404, "POST /v1/chat/completions or /bench/verdict")
686
+ model = body.get("model") or ""
687
+ if not isinstance(model, str):
688
+ return self._error(400, '"model" must be a collection name such as bench/core')
689
+ model, suffix = bench_collections.split_reply(model)
690
+ name = bench_collections.resolve(model)
691
+ if name is None:
692
+ return self._error(400 if not model else 404, bench_collections.choose_message(model))
693
+ model = bench_collections.PREFIX + name
694
+ messages, nominated, opts = body.get("messages"), body.get("with") or [], body.get("bench") or {}
695
+ if not (isinstance(messages, list) and all(isinstance(m, dict) for m in messages)):
696
+ return self._error(400, '"messages" must be a list of message objects')
697
+ if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
698
+ return self._error(400, '"with" must be a list of model aliases')
699
+ if not isinstance(opts, dict) or opts.get("reply", "all") not in bench_collections.REPLIES:
700
+ return self._error(400, '"bench" must be an object whose "reply" is "all" or "merged"')
701
+ if not guard.may_call(self.who, name):
702
+ allowed = ", ".join(bench_collections.PREFIX + c for c in guard.scope(self.who) or []) or "none"
703
+ return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}")
704
+ try:
705
+ guard.spend(self.who) # counted once it names a collection it may call
706
+ except guard.Refused as r:
707
+ return self._error(r.status, str(r), r.headers)
708
+ try:
709
+ # The body is the most specific, then the suffix, then the collection.
710
+ content, bench = run_collection(name, messages, nominated, opts.get("reply") or suffix, self.who)
711
+ except TooLarge as exc:
712
+ return self._error(413, str(exc))
713
+ except ValueError as exc:
714
+ return self._error(400, str(exc))
715
+ except LookupError as exc:
716
+ return self._error(503, str(exc))
717
+ reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
718
+ if not body.get("stream"):
719
+ return self._json(200, {
720
+ "id": reply_id, "object": "chat.completion", "created": created, "model": model,
721
+ "choices": [{"index": 0, "finish_reason": "stop",
722
+ "message": {"role": "assistant", "content": content}}], "bench": bench})
723
+ # A panel finishes all at once, so a streaming client gets it as one
724
+ # chunk, merged or not. ponytail: nothing is sent until the panel and
725
+ # the merge are done, so a client or proxy with a shorter idle timeout
726
+ # drops it. Keep-alives mean sending the 200 before the panel runs, so
727
+ # the 413/503 refusals move into the stream; streaming the merge as it
728
+ # is written needs a streaming client.chat (RELEASE-PLAN acceptance 11).
729
+ self.send_response(200)
730
+ self.send_header("Content-Type", "text/event-stream")
731
+ self.end_headers()
732
+ for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
733
+ chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
734
+ "model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
735
+ **({"bench": bench} if finish is None else {})}
736
+ self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
737
+ self.wfile.write(b"data: [DONE]\n\n")
738
+
739
+ def log_request(self, code="-", size="-"): # route, status, who; never a query or a key
740
+ guard.access(self, code)
741
+
742
+ def log_message(self, fmt, *args): # quieter than the default
743
+ sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
744
+
745
+
746
+ def main() -> None:
747
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
748
+ address = guard.bind()
749
+ guard.startup(address)
750
+ print(f"bench gateway on http://{address}:{PORT}/v1 — models: {', '.join(models())}")
751
+ ThreadingHTTPServer((address, PORT), Handler).serve_forever()
752
+
753
+
754
+ if __name__ == "__main__":
755
+ main()