@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
package/gateway.py CHANGED
@@ -1,755 +1,939 @@
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
+ OTHER FORMATS, AND STREAMING
77
+ ----------------------------
78
+ The same panel answers Anthropic Messages (/v1/messages), OpenAI Responses
79
+ (/v1/responses) and legacy completions (/v1/completions), Ollama (/api/chat,
80
+ /api/generate, /api/tags, /api/version, /api/show) and Gemini
81
+ (/v1beta/models/<collection>:generateContent and :streamGenerateContent):
82
+ metrics/formats.py turns each request into OpenAI messages and the answer
83
+ back into the format's reply. A request carrying tools is refused (400): a
84
+ panel's text is not a tool call.
85
+
86
+ Asked to stream, the reply's 200 goes as soon as the panel is seated (a
87
+ refusal before that keeps its status), keep-alives go while the seats
88
+ think, each seat's section goes as that seat finishes (a merged reply when
89
+ the merge is done), and an error after the 200 goes into the stream in the
90
+ format's own shape (Handler._stream).
91
+
92
+ WHAT IT IS NOT
93
+ --------------
94
+ A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
95
+ exists only for the multi-model work. Seats are called with `llm -m`, exactly
96
+ as offloads always were, or the built-in client (BENCH_CLIENT), so endpoints,
97
+ headers and per-model options all come from the same registry. Full prompts
98
+ and answers go to `runs/gateway/`, gitignored, with the name of the gateway
99
+ key that asked. Bound to 127.0.0.1, like the dashboard (BENCH_BIND and
100
+ BENCH_GATEWAY_PORT change that; beyond this machine it asks for a gateway key,
101
+ metrics/guard.py, which also covers keys on this machine, scopes, caps and
102
+ CORS). The dashboard answers these same routes on its own port.
103
+ """
104
+
105
+ from __future__ import annotations
106
+
107
+ import datetime as dt
108
+ import json
109
+ import os
110
+ import pathlib
111
+ import queue
112
+ import re
113
+ import secrets
114
+ import subprocess
115
+ import sys
116
+ import threading
117
+ import time
118
+ import urllib.parse
119
+ from concurrent.futures import ThreadPoolExecutor, as_completed
120
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
121
+
122
+ ROOT = pathlib.Path(__file__).resolve().parent
123
+ sys.path.insert(0, str(ROOT / "metrics"))
124
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
125
+ PORT = int(os.environ.get("BENCH_GATEWAY_PORT") or 8123)
126
+ LOG_DIR = DATA / "runs" / "gateway"
127
+ # The suite's own timeout. Above the slowest real answer seen (383 s); at 900
128
+ # one stuck explorer held a whole request for fifteen minutes.
129
+ SEAT_TIMEOUT = 420
130
+ sys.path.insert(0, str(ROOT / "metrics"))
131
+ sys.path.insert(0, str(ROOT / "suite"))
132
+ import bench_collections # noqa: E402
133
+ import client # noqa: E402
134
+ import decide # noqa: E402
135
+ import formats # noqa: E402
136
+ import guard # noqa: E402
137
+ import model_roles # noqa: E402
138
+ import offload # noqa: E402
139
+ import route # noqa: E402
140
+
141
+ # What the shape picker is asked to choose between. Each option carries a
142
+ # description because bare names mislead it: Mercury Decide called "list every
143
+ # place serve.py writes a file" `code` at 0.93 when given names alone.
144
+ SHAPES = {
145
+ "general": "A general question or task that fits none of the other kinds.",
146
+ "design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
147
+ "prose": "Writing or editing documentation or other text.",
148
+ "review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
149
+ "code": "Writing new code or changing existing code.",
150
+ "enumeration": "Listing every place something happens in given code or files.",
151
+ "quick": "A short, bounded task: classify, extract or summarise a small input.",
152
+ }
153
+ CONFIDENT = 0.6
154
+ LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
155
+ # Bytes. A million-token prompt is about 4 MB, and a photo sent as a data URL
156
+ # is 4/3 of its file. 20 MB is the tightest whole-request limit among the big
157
+ # providers (Gemini's for inline data), so what passes here they take too.
158
+ MAX_BODY = 20_000_000
159
+ TOO_BIG = (" Send images as http(s) URLs, or smaller ones: models scale a photo down to about"
160
+ " 1,500-2,000 pixels on its long side anyway.")
161
+ IMAGE_TOKENS = 2_500 # per image, in the size check (estimate_tokens says why)
162
+ IMAGE_URL = re.compile(r"(https?://[^\s/?#]+|data:image/[\w.+-]+;base64,)", re.I)
163
+ DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
164
+ URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
165
+ BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
166
+ HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
167
+ SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
168
+ QUERY_KEY = re.compile(r"([?&](?:key|api_key|access_token)=)[^&\s'\"]*", re.I)
169
+ # A streaming reply: how long a request may take to be seated before its 200
170
+ # goes anyway (a refusal before then keeps its status), and how often a
171
+ # keep-alive goes while the seats think. Idle timeouts in clients and proxies
172
+ # start around 30 seconds.
173
+ STREAM_GRACE = 1.0
174
+ KEEPALIVE = 10.0
175
+ # A merge that reads as consensus hides the dissent the bench runs on, so the
176
+ # merger is told to keep who said what.
177
+ MERGE_SYSTEM = ("Several models answered the same request. Write one answer to it from theirs: say where"
178
+ " they agreed, where they disagreed, and who said what, naming each model as labelled."
179
+ " Keep every dissent, and do not present a claim as settled because several made it.")
180
+
181
+
182
+ def flatten(messages: list[dict]) -> tuple[str, str]:
183
+ """(system, prompt) for `llm`. A panel question is one prompt; a longer
184
+ conversation is kept as labelled turns rather than silently truncated.
185
+ An image is its placeholder: this text is what gets written down."""
186
+ def text(m):
187
+ c = m.get("content") or ""
188
+ return c if isinstance(c, str) else "\n".join(
189
+ placeholder(_url(p)) if p.get("type") == "image_url" else str(p.get("text") or "")
190
+ for p in c if isinstance(p, dict))
191
+ # Newer OpenAI SDKs send "developer" where older ones send "system".
192
+ lead = ("system", "developer")
193
+ system = "\n\n".join(text(m) for m in messages if m.get("role") in lead)
194
+ turns = [m for m in messages if m.get("role") not in lead]
195
+ if len(turns) == 1:
196
+ return system, text(turns[0])
197
+ return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
198
+
199
+
200
+ def _url(part: dict) -> str:
201
+ """An image part's URL: {"image_url": {"url": ...}} as OpenAI sends it, or a bare string."""
202
+ u = part.get("image_url")
203
+ u = u.get("url") if isinstance(u, dict) else u
204
+ return u if isinstance(u, str) else ""
205
+
206
+
207
+ def images(messages: list[dict]) -> list[str]:
208
+ """Every image part's URL, in order. ValueError for one no seat could be
209
+ sent: the gateway never fetches an image, the upstream does, so only
210
+ http(s) URLs and inline base64 images pass; and an image in another API's
211
+ shape is refused rather than dropped, which would have a seat answer blind."""
212
+ urls = []
213
+ for m in messages:
214
+ parts = m.get("content")
215
+ for p in parts if isinstance(parts, list) else ():
216
+ kind = p.get("type") if isinstance(p, dict) else None
217
+ if kind in ("image", "input_image"):
218
+ raise ValueError(f'a "{kind}" part is another API\'s shape; send each image as'
219
+ ' {"type": "image_url", "image_url": {"url": "<http(s) or data: URL>"}}')
220
+ if kind == "image_url":
221
+ url = _url(p)
222
+ if not IMAGE_URL.match(url):
223
+ raise ValueError("an image_url must be an http(s) URL or a data:image/...;base64, URL,"
224
+ f" not {placeholder(url) if url else 'empty'}")
225
+ urls.append(url)
226
+ return urls
227
+
228
+
229
+ def placeholder(url: str) -> str:
230
+ """How an image is written down (the call log, offloads, the dashboard,
231
+ error text, the merger's copy of the request), never its bytes: a data
232
+ URL by its size, an http URL without its query (a signed URL's signature
233
+ is a credential) or a user:password."""
234
+ if url[:5].lower() == "data:":
235
+ return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
236
+ u = urllib.parse.urlsplit(url)
237
+ return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
238
+
239
+
240
+ def redact(text: str) -> str:
241
+ """`text` with each inline image as its placeholder, every http(s) URL
242
+ without its query or user:password, and any other long base64 run as its
243
+ size: an upstream may echo the request in an error, in whatever form, and
244
+ errors are logged, shown and learned from."""
245
+ def bare(m):
246
+ try:
247
+ u = urllib.parse.urlsplit(m.group())
248
+ except ValueError: # an unparseable netloc, such as "[::1"
249
+ return "[url]"
250
+ return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
251
+ text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
252
+ return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
253
+
254
+
255
+ def cannot_see(alias: str) -> str:
256
+ """Why an image cannot be put to `alias`, or "" when it can. The catalog
257
+ decides (metrics/catalog.py: the model's input types, your Vision / OCR
258
+ override in its model popup, a capability seen working). A model the
259
+ catalog knows nothing about does not sit either: one that cannot see
260
+ answers anyway, describing a photo it never got, and the override is one
261
+ click."""
262
+ import catalog
263
+ import endpoints
264
+ if endpoints.entry_of(alias).get("api") == "responses":
265
+ return "reached through the Responses API, which the gateway sends no images to yet"
266
+ rec = catalog.info(alias)
267
+ if rec.get("vision"):
268
+ return ""
269
+ if rec.get("vision") is None:
270
+ return "the catalog does not say whether it takes images; tick Vision / OCR in its model popup if it does"
271
+ return f"does not take images (says {rec['sources'].get('vision', 'the catalog')})"
272
+
273
+
274
+ def pick_shape(prompt: str) -> tuple[str, str]:
275
+ """(shape, how it was chosen). Falls back to general on any doubt."""
276
+ try:
277
+ held = set(json.loads((DATA / "config" / "model-roles.json").read_text(encoding="utf-8"))
278
+ .get("ad_hoc", []))
279
+ except (OSError, ValueError, AttributeError):
280
+ return "general", "config/model-roles.json unreadable"
281
+ picker = next((a for a, d in decide.load().items()
282
+ if "choice" in d["question_types"] and a not in held), None)
283
+ if not picker:
284
+ return "general", "no decision model onboarded"
285
+ question = {"shape": {"type": "choice",
286
+ "instructions": "What kind of task does this prompt ask for?",
287
+ "criteria": SHAPES}}
288
+ try:
289
+ answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
290
+ except Exception as exc: # noqa: BLE001
291
+ return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
292
+ conf = answer.get("confidence", 0)
293
+ if answer.get("choice") not in SHAPES or conf < CONFIDENT:
294
+ return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
295
+ return answer["choice"], f"{picker}, {conf:.2f}"
296
+
297
+
298
+ def retired() -> dict[str, dict]:
299
+ """config/retired.json: alias -> its record (when, why). Retiring a model
300
+ takes it out of every collection (serve.remove_model); this is for a
301
+ collection that still names one -- a hand edit, or one saved before that
302
+ -- so the seat is skipped with a reason instead of failing on the call."""
303
+ try:
304
+ data = json.loads((DATA / "config" / "retired.json").read_text(encoding="utf-8"))
305
+ except (OSError, ValueError):
306
+ return {}
307
+ return data if isinstance(data, dict) else {}
308
+
309
+
310
+ def retired_why(entry: dict) -> str:
311
+ when = str(entry.get("at", ""))[:10] if isinstance(entry, dict) else ""
312
+ return (f"retired from the bench{' on ' + when if when else ''}; take it out of the collection"
313
+ " (Restore in its model popup brings it back)")
314
+
315
+
316
+ def retiring() -> set[str]:
317
+ """Aliases not to spend an explorer seat on: those on a retiring alert
318
+ (failing their probes; 2026-10-16 both explorers of a bench/core call were
319
+ and both failed), and those cooling down after a rate limit, until the
320
+ wait the provider stated, or 30 minutes (metrics/learn.py)."""
321
+ out: set[str] = set()
322
+ try:
323
+ import alerts
324
+ out |= {a.get("alias", "") for a in alerts.active().values() if a.get("kind") == "retiring"}
325
+ except Exception: # noqa: BLE001
326
+ pass
327
+ try:
328
+ import learn
329
+ out |= set(learn.cooldowns())
330
+ except Exception: # noqa: BLE001
331
+ pass
332
+ return out
333
+
334
+
335
+ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None = None) -> dict:
336
+ """One seat's answer. `messages`: the request as sent, when it carries
337
+ images; it goes through the built-in client whatever BENCH_CLIENT says,
338
+ since `llm -m` takes a prompt, not inline images."""
339
+ cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
340
+ started = time.monotonic()
341
+ if messages is not None or client.backend(alias) == "builtin":
342
+ # No subprocess, and no shared llm log for parallel seats to lock.
343
+ res = client.chat(alias, prompt, system, SEAT_TIMEOUT, messages=messages)
344
+ answer = res["text"]
345
+ error = res["error"] or (f"no answer in {SEAT_TIMEOUT}s" if res["timed_out"] else "")
346
+ else:
347
+ answer, error = _ask_llm(cmd, prompt)
348
+ # A quota or credit notice delivered as the reply's text, with a success
349
+ # exit, is not an answer (suite/run.py PROVIDER_NOTICES).
350
+ from run import provider_notice
351
+ if not error and (notice := provider_notice(answer)):
352
+ error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
353
+ if messages is not None:
354
+ # Before the error is cut, logged or learned from. ponytail: the answer
355
+ # is not redacted; a seat gets an image as pixels, not as its URL or bytes.
356
+ error = redact(error)
357
+ if error:
358
+ # A stated context window, rate limit or wait is kept (metrics/learn.py);
359
+ # a rate limit also cools the model, so explorer seats skip it.
360
+ try:
361
+ import learn
362
+ learn.record(alias, error, "gateway")
363
+ except Exception: # noqa: BLE001
364
+ pass
365
+ status = "error" if error else ("empty" if not answer else "ok")
366
+ return {"status": status, "answer": answer, "error": error[-500:],
367
+ "seconds": round(time.monotonic() - started, 1)}
368
+
369
+
370
+ def _ask_llm(cmd: list[str], prompt: str) -> tuple[str, str]:
371
+ """(answer, error) from the `llm` CLI."""
372
+ try:
373
+ done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
374
+ encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
375
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"})
376
+ answer = done.stdout.strip()
377
+ # A non-zero exit is an error even when stderr is silent, unless it is
378
+ # only llm failing to log an answer it printed (client.py).
379
+ failed = done.returncode and not client.llm_log_failed_only(done.stdout, done.stderr)
380
+ error = (done.stderr.strip() or f"llm exited {done.returncode}") if failed else ""
381
+ except subprocess.TimeoutExpired:
382
+ answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
383
+ except Exception as exc: # noqa: BLE001
384
+ # One seat failing to start must not lose the other seats' records.
385
+ answer, error = "", f"{type(exc).__name__}: {exc}"
386
+ return answer, error
387
+
388
+
389
+ class TooLarge(ValueError):
390
+ """The prompt does not fit a member's context window (HTTP 413)."""
391
+
392
+
393
+ def estimate_tokens(text: str) -> int:
394
+ """Characters / 4 plus 10%: tokenizers differ by family, so this errs high.
395
+ The windows it is compared with are corrected by learned 'maximum context
396
+ length' errors (metrics/learn.py). An image adds IMAGE_TOKENS whatever its
397
+ size, which errs high too: OpenAI's, Anthropic's and Gemini's models spend
398
+ up to about 1,600 on one at their usual detail. Qwen and Llama vision
399
+ models can spend more on a very large image; a learned error corrects that."""
400
+ return formats.estimate(text)
401
+
402
+
403
+ def prompt_tokens(messages: list[dict]) -> int:
404
+ """The estimate for a whole request, images counted: what a reply's usage
405
+ and a count_tokens route say."""
406
+ system, prompt = flatten(messages)
407
+ try:
408
+ pictures = len(images(messages))
409
+ except ValueError: # refused by run_collection, which says why
410
+ pictures = 0
411
+ return estimate_tokens(f"{system}\n\n{prompt}".strip()) + IMAGE_TOKENS * pictures
412
+
413
+
414
+ def too_small(needed: int, aliases) -> dict[str, int]:
415
+ """Aliases whose KNOWN context window is under `needed`, with the window."""
416
+ import catalog
417
+ out = {}
418
+ for a in aliases:
419
+ cw = catalog.info(a).get("context_window")
420
+ if cw and cw < needed:
421
+ out[a] = cw
422
+ return out
423
+
424
+
425
+ def record_seat(task: str, call: str, label: str, alias: str, r: dict) -> dict:
426
+ """Log one answer as an unjudged offload, the moment it is in, so explorer
427
+ rotation counts it even if nobody judges; returns the seat as logged."""
428
+ offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
429
+ + (f": {r['error'][:120]}" if r["error"] else ""), call)
430
+ return {"call": call, "seat": label, "alias": alias, **r}
431
+
432
+
433
+ def merger_candidates(collection: dict, members: list[str], shape: str, large: bool):
434
+ """Who may merge, best first. The collection's `merger` alone when set;
435
+ else the members with verdicts on this shape, most useful first, then the
436
+ router's primary, then the other members (a fresh install has neither
437
+ verdicts nor suite scores). A generator, so the router is asked only if
438
+ no member with a record fits."""
439
+ if collection.get("merger"):
440
+ yield collection["merger"]
441
+ return
442
+ record: dict[str, list[int]] = {}
443
+ for e in offload.entries():
444
+ # The shape ends each seat's task as run_collection writes it: "(bench/<name>, <shape>)".
445
+ if e["model"] in members and e.get("useful") is not None and e.get("task", "").endswith(f", {shape})"):
446
+ r = record.setdefault(e["model"], [0, 0])
447
+ r[0], r[1] = r[0] + bool(e["useful"]), r[1] + 1
448
+ # Useful verdicts counted, not a ratio, as route.py counts findings: one
449
+ # lucky verdict must not outrank a long good record.
450
+ yield from sorted(record, key=lambda a: (-record[a][0], -record[a][0] / record[a][1]))
451
+ try:
452
+ primary = route.panel(shape, large=large)["primary"]
453
+ except Exception: # noqa: BLE001 -- the members can still merge
454
+ primary = None
455
+ if primary:
456
+ yield primary["model"]
457
+ yield from members
458
+
459
+
460
+ def merge(collection: dict, members: list[str], shape: str, whole: str, seats: list[dict],
461
+ task: str, panel_id: str) -> tuple[dict | None, str]:
462
+ """The merger's seat, and why the reply falls back to every answer ("" if
463
+ it does not). The merger reads the request and every answer, so it gets
464
+ the members' size check; a failed merger is still logged like a seat."""
465
+ answered = [s for s in seats if s["status"] == "ok"]
466
+ if not answered:
467
+ return None, "no seat answered"
468
+ text = f"THE REQUEST\n\n{whole}\n\nTHE ANSWERS\n\n" + "\n\n".join(
469
+ f"## {s['seat']} — {s['alias']}\n\n{s['answer']}" for s in answered)
470
+ needed = estimate_tokens(f"{MERGE_SYSTEM}\n\n{text}")
471
+ alias = next((a for a in merger_candidates(collection, members, shape, len(text) > LARGE)
472
+ if not too_small(needed, [a])), None)
473
+ if alias is None:
474
+ return None, f"no merger has the context window for the merge (about {needed:,} tokens)"
475
+ seat = record_seat(task, f"{panel_id}.merger", "merger", alias, ask_seat(alias, MERGE_SYSTEM, text))
476
+ if seat["status"] != "ok":
477
+ return seat, f"the merger {alias} came back {seat['status']}" + (f": {seat['error'][:120]}" if seat["error"] else "")
478
+ return seat, ""
479
+
480
+
481
+ def section(seat: dict) -> str:
482
+ """One seat's part of an every-answer reply: its heading, call id, status and answer."""
483
+ body = seat["answer"] or (f"_error: {seat['error'][:300]}_" if seat["error"] else
484
+ "_empty reply — usually a token budget, not incapacity_")
485
+ return f"## {seat['seat']} — {seat['alias']}\n`{seat['call']}` · {seat['status']} · {seat['seconds']}s\n\n{body}\n"
486
+
487
+
488
+ def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
489
+ reply: str = "", who: str = "local", on_part=None) -> tuple[str, dict]:
490
+ """The panel's reply as markdown, and the same seats as data for the
491
+ response's `bench` field. `reply`: the request's mode, "" for the
492
+ collection's own. `who`: the gateway key that asked, for the call log.
493
+
494
+ `on_part`, for a streaming caller: called with each piece of an
495
+ every-answer reply as it is ready, the heading once the panel is seated,
496
+ each seat's section as that seat finishes, then the closing lines. The
497
+ pieces add up to the content, with the seats in the order they finished
498
+ rather than seat order. Not called for a merged reply: the merge is the
499
+ reply, and it comes at the end (or every answer, if it fails)."""
500
+ pictures = images(messages)
501
+ system, prompt = flatten(messages)
502
+ if not prompt.strip():
503
+ raise ValueError("no user message to put to the collection")
504
+ collection = bench_collections.load()[name]
505
+ s = bench_collections.settings()
506
+ raw = collection.get("members") or []
507
+ # The editor dedupes and caps; a hand-edited file may not have.
508
+ members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
509
+ # A RETIRED MODEL IS NOT CALLED: it is no longer registered, so the call
510
+ # could only fail ("Unknown model"). Skipped, and named, before the cap so
511
+ # it frees its seat for the next member or an explorer. Retiring takes it
512
+ # out of the collection, so this is a collection that still names one.
513
+ gone = retired()
514
+ skipped = [{"seat": "member", "alias": a, "why": retired_why(gone[a])} for a in members if a in gone]
515
+ members = [a for a in members if a not in gone][:s["max_calls"]]
516
+ # A member named in `with` is already seated; it must not use up the slot.
517
+ nominated = [a for a in dict.fromkeys(nominated or []) if a not in members]
518
+ skipped += [{"seat": "with", "alias": a, "why": retired_why(gone[a])} for a in nominated
519
+ if a in gone and a not in {k["alias"] for k in skipped}]
520
+ nominated = [a for a in nominated if a not in gone]
521
+ merged = (reply or collection.get("reply") or "all") == "merged"
522
+ if collection.get("merger") in gone:
523
+ if merged:
524
+ skipped.append({"seat": "merger", "alias": collection["merger"],
525
+ "why": retired_why(gone[collection["merger"]]) + "; the merger is chosen by verdicts"})
526
+ collection = {**collection, "merger": ""}
527
+ n_explorers = bench_collections.explorers_for(len(members), s)
528
+ # AN IMAGE GOES ONLY TO A MODEL THAT CAN SEE IT: one that cannot would
529
+ # answer anyway, about a picture it never got. Skipped, and named.
530
+ skipped += [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
531
+ for a in group if pictures and (why := cannot_see(a))]
532
+ # A VISION SPECIALIST SITS ONLY FOR PICTURES: its role says it reads
533
+ # images, not that it answers text, so on a text request its seat goes to
534
+ # the next member or an explorer. Named in `with`, it sits anyway.
535
+ if not pictures:
536
+ roles = model_roles.load()["models"]
537
+ skipped += [{"seat": "member", "alias": a, "why": "a vision specialist: sits only when the request"
538
+ " carries images"} for a in members if roles.get(a) == "vision"]
539
+ unseen = {k["alias"] for k in skipped}
540
+ sitting = [a for a in members if a not in unseen]
541
+ nominated = [a for a in nominated if a not in unseen]
542
+ # The system text goes to every seat too, so it counts for shape and size.
543
+ whole = f"{system}\n\n{prompt}".strip()
544
+ # REFUSE WHAT A MEMBER CANNOT READ, so the caller can trim the prompt or
545
+ # pick another collection instead of collecting a seat's context error.
546
+ needed = estimate_tokens(whole) + IMAGE_TOKENS * len(pictures)
547
+ small = too_small(needed, sitting)
548
+ if small:
549
+ windows = ", ".join(f"{a} {cw:,}" for a, cw in sorted(small.items(), key=lambda kv: kv[1]))
550
+ counted = f" (each image counted as {IMAGE_TOKENS:,})" if pictures else ""
551
+ raise TooLarge(f"prompt is about {needed:,} tokens{counted}; too large for {windows} (context"
552
+ f" windows) in bench/{name}. Trim it under {min(small.values()):,} tokens,"
553
+ " or choose a collection whose members have larger windows"
554
+ " (GET /v1/models lists each collection's context_window).")
555
+ if pictures and not collection.get("shape"):
556
+ shape, how = "vision", "the request carries images"
557
+ elif not n_explorers:
558
+ shape, how = collection.get("shape") or "general", "no explorer seats to fill"
559
+ elif collection.get("shape"):
560
+ shape, how = collection["shape"], "set on the collection"
561
+ else:
562
+ shape, how = pick_shape(whole)
563
+ seats = [(f"member{i}", "member", alias) for i, alias in enumerate(sitting, 1)]
564
+ if n_explorers:
565
+ # `with` in the request body is route.py's --with: an explorer seat.
566
+ from run import _registered_names
567
+ avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
568
+ if pictures:
569
+ avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
570
+ p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
571
+ members=sitting, explorers=n_explorers, avoid=avoid)
572
+ seats += [(key, label, p[key]["model"]) for key, label in
573
+ (("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
574
+ if not seats and pictures:
575
+ raise ValueError(f"no seat in bench/{name} can see images, so none is asked rather than answer"
576
+ " blind: " + ("; ".join(f"{k['alias']} {k['why']}" for k in skipped) or "no members")
577
+ + ", and no model that takes images was free for an explorer seat. Add one to the"
578
+ " collection, or call one whose together.capabilities on GET /v1/models lists vision.")
579
+ if not seats:
580
+ raise LookupError(f"collection {name!r} has no members and no explorer could be seated"
581
+ + "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
582
+ panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
583
+ model = bench_collections.PREFIX + name
584
+ task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
585
+ out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
586
+ f" explorers · explorer shape {shape} ({how})\n"]
587
+ if skipped:
588
+ out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
589
+ emit = None if merged else on_part
590
+ if emit:
591
+ emit("\n".join(out))
592
+ done: dict[int, dict] = {}
593
+ with ThreadPoolExecutor(max_workers=len(seats)) as pool:
594
+ asked = {pool.submit(ask_seat, alias, system, prompt, messages if pictures else None): i
595
+ for i, (_key, _label, alias) in enumerate(seats)}
596
+ # Each seat is logged the moment it answers, so explorer rotation
597
+ # counts it even if a slower seat (or the caller) never finishes.
598
+ for future in as_completed(asked):
599
+ i = asked[future]
600
+ key, label, alias = seats[i]
601
+ done[i] = record_seat(task, f"{panel_id}.{key}", label, alias, future.result())
602
+ if emit:
603
+ emit("\n" + section(done[i]))
604
+
605
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
606
+ record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
607
+ "model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
608
+ "prompt": prompt, "seats": [done[i] for i in range(len(seats))],
609
+ **({"skipped": skipped} if skipped else {})}
610
+ out += [section(s) for s in record["seats"]]
611
+ footer = ("---\nRecord a verdict per seat once checked against the code:\n"
612
+ "`POST /bench/verdict` on this same server (see `bench.verdict`), or"
613
+ " `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
614
+ out.append(footer)
615
+ if emit:
616
+ emit("\n" + footer)
617
+ content = "\n".join(out)
618
+ # Beyond the OpenAI fields, so a caller reads seats without parsing the
619
+ # markdown. Clients that do not know it ignore it.
620
+ bench = {"panel": panel_id, "collection": model, "shape": shape, "shape_by": how,
621
+ "seats": [{k: s[k] for k in SEAT_KEYS} for s in record["seats"]],
622
+ # A path, not a URL: the same server answered, whichever port or
623
+ # proxy the caller came through (the dashboard serves these routes too).
624
+ "verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
625
+ **({"skipped": skipped} if skipped else {})}
626
+ if merged:
627
+ try:
628
+ merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
629
+ except Exception as exc: # noqa: BLE001 -- never lose the panel to the merge
630
+ merger, note = None, f"the merge failed ({type(exc).__name__}: {exc})"
631
+ if merger:
632
+ bench["merger"] = {k: merger[k] for k in SEAT_KEYS}
633
+ record["seats"].append(merger) # the dashboard shows it, verdict and all
634
+ if note:
635
+ bench.update(reply="all", note=note)
636
+ content = f"_Merged reply unavailable: {note}. Every answer follows._\n\n{content}"
637
+ else:
638
+ who = ", ".join(f"{s['alias']} {s['status']}" for s in bench["seats"])
639
+ bench["reply"] = "merged"
640
+ content = (f"{merger['answer']}\n\n---\nMerged by {merger['alias']} (`{merger['call']}`) from:"
641
+ f" {who}. Every seat's answer and call id is in the response's `bench` field.")
642
+ with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
643
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
644
+ return content, bench
645
+
646
+
647
+ def models() -> list[str]:
648
+ return [bench_collections.PREFIX + n for n in bench_collections.load()]
649
+
650
+
651
+ def listed(who: str) -> list[tuple[str, dict]]:
652
+ """(model name, what it is for) for every collection key `who` may call,
653
+ each twice: as set, and in the other reply mode, so a client that offers
654
+ only listed models can still pick either. What GET /v1/models, /api/tags
655
+ and /v1beta/models list, each in its own shape."""
656
+ s, rows = bench_collections.settings(), []
657
+ for n, c in bench_collections.load().items():
658
+ if not guard.may_call(who, n): # a key limited to some collections sees those
659
+ continue
660
+ d = bench_collections.described(n, c, s)
661
+ other = "all" if d["reply"] == "merged" else "merged"
662
+ rows += [(bench_collections.PREFIX + n, d), (f"{bench_collections.PREFIX}{n}:{other}", {**d, "reply": other})]
663
+ return rows
664
+
665
+
666
+ def bench_headers(bench: dict) -> dict:
667
+ """The panel's call ids as headers, for a format with no room for the
668
+ bench field in its body (Gemini): X-Bench-Panel, and X-Bench-Seats as
669
+ JSON, without the answers (they are in the reply)."""
670
+ seats = bench.get("seats", []) + ([bench["merger"]] if bench.get("merger") else [])
671
+ return {"X-Bench-Panel": str(bench.get("panel", "")),
672
+ "X-Bench-Seats": json.dumps([{k: s.get(k) for k in ("call", "seat", "alias", "status")} for s in seats])}
673
+
674
+
675
+ def refusal(exc: Exception) -> tuple[int, str]:
676
+ """The status and message for a panel that could not run."""
677
+ if isinstance(exc, TooLarge):
678
+ return 413, str(exc)
679
+ if isinstance(exc, ValueError):
680
+ return 400, str(exc)
681
+ if isinstance(exc, LookupError):
682
+ return 503, str(exc)
683
+ return 500, f"the panel failed ({type(exc).__name__}: {exc})"
684
+
685
+
686
+ class Handler(BaseHTTPRequestHandler):
687
+ """The gateway's routes. serve.py's Handler subclasses this to answer them
688
+ on the dashboard's port too, so the checks use the port actually served."""
689
+ who = "-"
690
+
691
+ def _json(self, code: int, data: dict, headers: dict | None = None) -> None:
692
+ body = json.dumps(data).encode()
693
+ self.send_response(code)
694
+ self.send_header("Content-Type", "application/json")
695
+ self.send_header("Content-Length", str(len(body)))
696
+ for k, v in (headers or {}).items():
697
+ self.send_header(k, v)
698
+ self.end_headers()
699
+ self.wfile.write(body)
700
+
701
+ def _error(self, code: int, message: str, headers: dict | None = None, fmt: str = "chat") -> None:
702
+ """A refusal, in the shape the route's clients read (metrics/formats.py)."""
703
+ self._json(code, formats.INBOUND[fmt].error(code, message), headers=headers)
704
+
705
+ def _query_key(self, fmt: str) -> str | None:
706
+ """A Gemini route's ?key=, as Google's REST examples send it; no other
707
+ route takes a key in its URL, where proxies and histories keep it."""
708
+ if fmt != "gemini":
709
+ return None
710
+ return urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("key", [None])[0]
711
+
712
+ def end_headers(self):
713
+ # CORS for a browser app on an allowed origin, on the gateway's routes
714
+ # only (metrics/guard.py); Vary, so a cache never hands one origin's
715
+ # answer to another. A request line too broken to parse has no headers.
716
+ headers = getattr(self, "headers", None)
717
+ if headers is not None and guard.gateway_route((getattr(self, "path", "") or "").split("?", 1)[0]) \
718
+ and (origin := guard.cors_origin(headers, self.server.server_port)):
719
+ self.send_header("Access-Control-Allow-Origin", origin)
720
+ self.send_header("Access-Control-Expose-Headers", "X-Bench-Panel, X-Bench-Seats")
721
+ self.send_header("Vary", "Origin")
722
+ super().end_headers()
723
+
724
+ def do_OPTIONS(self): # noqa: N802
725
+ """A browser's CORS preflight: answered on the gateway's routes, for an
726
+ allowed origin only. The dashboard's routes answer none, as before."""
727
+ if not guard.gateway_route(self.path.split("?", 1)[0]):
728
+ return self.send_error(501, f"Unsupported method ({self.command!r})")
729
+ try:
730
+ guard.check(self.headers, self.server.server_port, post=False, max_body=0)
731
+ except guard.Refused as r:
732
+ return self._error(r.status, str(r))
733
+ if not guard.cors_origin(self.headers, self.server.server_port):
734
+ return self._error(403, f"Origin {self.headers.get('Origin') or '(none)'!r} is not allowed;"
735
+ " set BENCH_ALLOWED_ORIGINS or BENCHWARMERS_PUBLIC_URL to add one")
736
+ # The headers it asks for as well, when they are plain names: the
737
+ # OpenAI JS SDK sends X-Stainless-* on every call, and a preflight that
738
+ # refused them would block it. The origin is the boundary, not these.
739
+ asked = (self.headers.get("Access-Control-Request-Headers") or "").strip()
740
+ self.send_response(204)
741
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
742
+ self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type"
743
+ + (f", {asked}" if HEADER_NAMES.fullmatch(asked) else ""))
744
+ self.send_header("Access-Control-Max-Age", "600")
745
+ self.end_headers()
746
+
747
+ def do_GET(self): # noqa: N802
748
+ path = self.path.split("?", 1)[0].rstrip("/")
749
+ fmt = formats.format_of(path)
750
+ try:
751
+ guard.check(self.headers, self.server.server_port, post=False, max_body=0)
752
+ self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
753
+ except guard.Refused as r:
754
+ return self._error(r.status, str(r), r.headers, fmt)
755
+ if path in ("/v1/models", "/models"):
756
+ # Beyond the OpenAI fields: what each collection is for, so an
757
+ # orchestrator can choose one by task. Clients ignore extra keys.
758
+ return self._json(200, {"object": "list", "data": [
759
+ {"id": n, "object": "model", "owned_by": "benchwarmers", **d} for n, d in listed(self.who)]})
760
+ if path == "/api/tags":
761
+ return self._json(200, formats.ollama_tags(listed(self.who)))
762
+ if path == "/api/version":
763
+ return self._json(200, {"version": formats.OLLAMA_VERSION})
764
+ if path == "/v1beta/models":
765
+ return self._json(200, formats.gemini_models(listed(self.who)))
766
+ if path.startswith("/v1beta/models/"):
767
+ name = urllib.parse.unquote(path[len("/v1beta/models/"):])
768
+ row = dict(listed(self.who)).get(name)
769
+ return self._json(200, formats.gemini_model(name, row)) if row else \
770
+ self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
771
+ self._error(404, "GET /v1/models, /api/tags, /api/version or /v1beta/models", fmt=fmt)
772
+
773
+ def do_POST(self): # noqa: N802
774
+ # Without this any web page could spend model calls, seat ad hoc
775
+ # models through `with`, or write verdicts (metrics/guard.py).
776
+ path = self.path.split("?", 1)[0].rstrip("/")
777
+ fmt = formats.format_of(path)
778
+ try:
779
+ length = guard.check(self.headers, self.server.server_port, post=True, max_body=MAX_BODY, cors=True)
780
+ self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
781
+ except guard.Refused as r:
782
+ return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers, fmt)
783
+ try:
784
+ body = json.loads(self.rfile.read(length) or b"{}")
785
+ except ValueError:
786
+ return self._error(400, "body is not JSON", fmt=fmt)
787
+ if not isinstance(body, dict):
788
+ return self._error(400, "body must be a JSON object", fmt=fmt)
789
+ if path == "/bench/verdict":
790
+ if not isinstance(body.get("useful"), bool):
791
+ return self._error(400, '"useful" must be true or false')
792
+ lists = {k: body.get(k) or [] for k in ("real", "false")}
793
+ if not all(isinstance(v, list) and all(isinstance(s, str) for s in v) for v in lists.values()):
794
+ return self._error(400, '"real" and "false" must be lists of one-sentence claims')
795
+ try:
796
+ e = offload.verdict(str(body.get("call", "")), body["useful"], str(body.get("note") or ""),
797
+ lists["real"], lists["false"])
798
+ except KeyError as exc:
799
+ return self._error(404, exc.args[0])
800
+ return self._json(200, {"ok": True, "recorded": e})
801
+ # Each format's request becomes one Ask (metrics/formats.py); a part
802
+ # that would be lost on the way, tools above all, is refused with why.
803
+ gemini = formats.gemini_route(path)
804
+ try:
805
+ if gemini and gemini[1] == "countTokens":
806
+ ask = formats.gemini_ask(body.get("generateContentRequest") or body, gemini[0])
807
+ return self._json(200, {"totalTokens": prompt_tokens(ask["messages"])})
808
+ if gemini:
809
+ if gemini[1] not in ("generateContent", "streamGenerateContent"):
810
+ return self._error(404, f"POST models/<collection>:generateContent or :streamGenerateContent,"
811
+ f" not :{gemini[1]}", fmt=fmt)
812
+ ask = formats.gemini_ask(body, gemini[0], gemini[1] == "streamGenerateContent")
813
+ elif path == "/v1/messages/count_tokens":
814
+ # An estimate, as the size check makes, without calling a model.
815
+ return self._json(200, {"input_tokens": prompt_tokens(formats.anthropic_ask(body)["messages"])})
816
+ elif path == "/api/show":
817
+ return self._show(body)
818
+ elif path in formats.POST_ROUTES:
819
+ ask = formats.INBOUND[fmt].ask(body)
820
+ else:
821
+ return self._error(404, "POST /v1/chat/completions, /v1/messages, /v1/responses, /v1/completions,"
822
+ " /api/chat, /api/generate, /v1beta/models/<collection>:generateContent"
823
+ " or /bench/verdict", fmt=fmt)
824
+ except formats.Unsupported as exc:
825
+ return self._error(400, str(exc), fmt=fmt)
826
+ alt = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("alt", [""])[0]
827
+ self._panel(fmt, ask, sse=alt == "sse")
828
+
829
+ def _show(self, body: dict) -> None:
830
+ """POST /api/show: Ollama's model card, for a collection the key may call."""
831
+ name = formats.ollama_model(body.get("model") or body.get("name") or "")
832
+ rows = dict(listed(self.who))
833
+ row = rows.get(name) or rows.get(bench_collections.PREFIX + str(name))
834
+ if not row:
835
+ return self._error(404, f"model {name!r} not found; GET /api/tags lists the collections",
836
+ fmt="ollama_chat")
837
+ self._json(200, formats.ollama_show(name, row))
838
+
839
+ def _panel(self, fmt: str, ask: dict, sse: bool = True) -> None:
840
+ """One panel, for a request in any format: the collection it names,
841
+ the key's scope and cap, then the panel, answered in that format."""
842
+ model, suffix = bench_collections.split_reply(ask["model"])
843
+ name = bench_collections.resolve(model)
844
+ if name is None:
845
+ return self._error(400 if not model else 404, bench_collections.choose_message(model), fmt=fmt)
846
+ model = bench_collections.PREFIX + name
847
+ if not guard.may_call(self.who, name):
848
+ allowed = ", ".join(bench_collections.PREFIX + c for c in guard.scope(self.who) or []) or "none"
849
+ return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}",
850
+ fmt=fmt)
851
+ try:
852
+ guard.spend(self.who) # counted once it names a collection it may call
853
+ except guard.Refused as r:
854
+ return self._error(r.status, str(r), r.headers, fmt)
855
+ reply = ask["reply"] or suffix # the body is the most specific, then the suffix, then the collection
856
+ # Chat completions have always answered with the collection's name;
857
+ # the other formats echo the name as asked, which their clients match.
858
+ shown = model if fmt == "chat" else ask["model"]
859
+ if ask["stream"]:
860
+ out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
861
+ return self._stream(fmt, name, ask, reply, out)
862
+ started = time.monotonic()
863
+ try:
864
+ content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who)
865
+ except (ValueError, LookupError) as exc: # TooLarge is a ValueError
866
+ return self._error(*refusal(exc), fmt=fmt)
867
+ usage = (prompt_tokens(ask["messages"]), estimate_tokens(content))
868
+ self._json(200, formats.INBOUND[fmt].reply(content, shown, bench, usage, time.monotonic() - started),
869
+ bench_headers(bench) if fmt == "gemini" else None)
870
+
871
+ def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream) -> None:
872
+ """The panel as it comes, in the format's stream shape: every answer
873
+ seat by seat as each finishes, or a merged reply when the merge is
874
+ done. The panel runs in its own thread and this one writes, so a
875
+ keep-alive goes out every KEEPALIVE seconds while the seats think. A
876
+ refusal raised before anything is ready (the size check, an image no
877
+ seat can see: milliseconds) still gets its status; the 200 goes once
878
+ the panel is seated or after STREAM_GRACE, whichever is first, and
879
+ from then on an error goes into the stream in the format's shape."""
880
+ events: queue.Queue = queue.Queue()
881
+
882
+ def work():
883
+ try:
884
+ events.put(("done", run_collection(name, ask["messages"], ask["with"], reply, self.who,
885
+ on_part=lambda text: events.put(("part", text)))))
886
+ except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
887
+ events.put(("error", exc))
888
+ threading.Thread(target=work, daemon=True).start()
889
+ started = streamed = False
890
+ while True:
891
+ try:
892
+ kind, data = events.get(timeout=KEEPALIVE if started else STREAM_GRACE)
893
+ except queue.Empty:
894
+ kind, data = "idle", None
895
+ if kind == "error" and not started:
896
+ return self._error(*refusal(data), fmt=fmt)
897
+ try:
898
+ if not started:
899
+ self.send_response(200)
900
+ self.send_header("Content-Type", out.content_type)
901
+ self.send_header("Cache-Control", "no-cache")
902
+ self.end_headers()
903
+ self.wfile.write(out.start())
904
+ started = True
905
+ if kind == "idle":
906
+ self.wfile.write(out.keepalive())
907
+ elif kind == "part":
908
+ self.wfile.write(out.text(data))
909
+ streamed = True
910
+ elif kind == "done":
911
+ content, bench = data
912
+ self.wfile.write((b"" if streamed else out.text(content)) + out.end(bench))
913
+ return
914
+ else:
915
+ self.wfile.write(out.error(*refusal(data)))
916
+ return
917
+ except OSError:
918
+ return # the caller left; the panel still finishes, every seat recorded
919
+
920
+ def log_request(self, code="-", size="-"): # route, status, who; never a query or a key
921
+ guard.access(self, code)
922
+
923
+ def log_message(self, fmt, *args): # quieter than the default
924
+ # http.server's own errors quote the request line, ?key= and all
925
+ # ("Bad request syntax ('GET /v1beta/models?key=...')").
926
+ line = QUERY_KEY.sub(r"\1...", fmt % args)
927
+ sys.stderr.write(f"{self.log_date_time_string()} {line}\n")
928
+
929
+
930
+ def main() -> None:
931
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
932
+ address = guard.bind()
933
+ guard.startup(address)
934
+ print(f"bench gateway on http://{address}:{PORT}/v1 — models: {', '.join(models())}")
935
+ ThreadingHTTPServer((address, PORT), Handler).serve_forever()
936
+
937
+
938
+ if __name__ == "__main__":
939
+ main()