@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.md CHANGED
@@ -1,272 +1,415 @@
1
- # Bench gateway — design
2
-
3
- Status: **built** (2026-10-16): `python gateway.py`, then point an OpenAI client
4
- at `http://127.0.0.1:8123/v1` with a collection as the model, e.g. `bench/core`.
5
- Diagram: `.archify/architecture-bench-gateway-20261016-182500/bench-gateway.html`.
6
-
7
- ## The idea in one paragraph
8
-
9
- The bench gets its own OpenAI-compatible endpoint whose only job is **the bench
10
- method**: one prompt, several different models, every answer back, every
11
- answer logged. You define **collections** — named groups of models such as
12
- `core` or `reviewers` — on the bench. A caller asks for `bench/<name>` and the
13
- prompt goes to every member of that collection plus one or two **explorer**
14
- seats the bench fills by staleness, so the roster keeps getting evidence even
15
- when the same collection is called every day. Each answer comes back labelled
16
- by model and seat with a call id; the caller checks the answers and records a
17
- verdict by call id, which lands in the evidence `route.py` reads. Using the
18
- bench and measuring it stay the same act, without anyone retyping an offload.
19
-
20
- Single-model calls are **not** the bench's job: chat and coding agents keep
21
- calling the upstream relay directly. The relay stays the engine; the bench
22
- gateway sits beside it for the multi-model work.
23
-
24
- ## Collections and explorer seats
25
-
26
- ```
27
- explorers = min(max_explorers, max_calls - members) defaults: 2 and 5
28
- ```
29
-
30
- | Members | Explorers | Calls |
31
- |---|---|---|
32
- | 1 | 2 | 3 |
33
- | 2 | 2 | 4 |
34
- | 3 | 2 | 5 |
35
- | 4 | 1 | 5 |
36
- | 5 | 0 | 5 |
37
-
38
- Two to four members is recommended. Explorers are never a member or a member's
39
- model family. Collections live in `config/collections.json`, the limits in
40
- `config/gateway.json`; both are edited in the dashboard's
41
- **Collections** tab and every change is logged in `config/changes.jsonl`.
42
-
43
- **Naming:** `bench/<name>`, lower-case letters, digits and `-`. The name says
44
- what the collection is *for* (`reviewers`, `design`), not who is in it, because
45
- members change. The `bench/` prefix keeps collections apart from real model ids
46
- in clients that merge model lists.
47
-
48
- **Choosing a collection by task:** each collection has a `description` (what
49
- it is for, up to 300 characters) and `tags` (up to 8 lower-case words).
50
- `GET /v1/models` returns them with the members, shape and explorer count, so an
51
- orchestrator can list the collections and pick the one that fits before it
52
- calls. Write the description for that reader.
53
-
54
- **No default collection.** A request that names no collection, or one that
55
- does not exist, is refused (400 / 404) with every collection's description and
56
- tags in the message, so a caller that could not choose learns how to. A
57
- `bench/auto` that guessed for it was considered and rejected: the caller knows
58
- the task, and a guess would run the wrong models silently.
59
-
60
- ## Prompt size
61
-
62
- The gateway estimates the prompt (system text included) at characters / 4 plus
63
- 10%, which errs high. If that is larger than any **member's** context window,
64
- the request is refused with **413** naming each member that is too small and
65
- its window, so the caller can trim the prompt or pick another collection;
66
- `GET /v1/models` gives each collection's `together.context_window` (the smallest
67
- known among its members) to check first. Explorer seats skip models too small
68
- for the prompt, as well as retiring and rate-limited ones.
69
-
70
- Context windows come from `metrics/catalog.py`: your overrides in the model
71
- popup, then windows learned from real "maximum context length" errors (only
72
- ever lower), then the daily refresh from each model's own endpoint listing,
73
- models.dev and FreeLLMAPI. A model with no known window is never refused.
74
- Each image adds 2,500 tokens to the estimate (see Images).
75
-
76
- ## Images
77
-
78
- A request whose messages carry OpenAI `image_url` parts, with an `https://`
79
- URL or a `data:image/...;base64,` URL, goes to every seat with its messages
80
- exactly as sent:
81
-
82
- ```json
83
- {"model": "bench/vision", "messages": [{"role": "user", "content": [
84
- {"type": "text", "text": "What is this chair, and what might it fetch?"},
85
- {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}]}]}
86
- ```
87
-
88
- - **Through the built-in client**, whatever `BENCH_CLIENT` says: the `llm`
89
- CLI takes a prompt, not inline images. A text-only request takes the same
90
- path as before.
91
- - **Only seats that can see.** A model sits only if the catalog says it takes
92
- images: its input types from its endpoint's listing, models.dev or
93
- OpenRouter, or **Vision / OCR** ticked in its model popup. A member that
94
- cannot, or that the catalog knows nothing about, is not asked and is listed
95
- in `bench.skipped` with why (`{"seat", "alias", "why"}`; a grey chip on the
96
- Gateway card); so is a `with` nomination. Explorer seats go only to models
97
- that take images. With no seat that can see, the request is refused (400)
98
- rather than answered blind. Unknown counts as cannot because a model that
99
- cannot see answers anyway, describing a photo it never got, and the popup
100
- fixes it in one click. A model reached through the Responses API (a ChatGPT
101
- plan) gets no images yet.
102
- - **A vision collection** is members that take images; `GET /v1/models` lists
103
- `vision` in its `together.capabilities` when every member does. An image
104
- request on a collection with no shape takes the shape `vision` (general and
105
- light models), so verdicts on image work are counted apart.
106
- - **Size.** The body cap is 20 MB: a photo as a data URL is 4/3 of its file,
107
- and 20 MB is the tightest whole-request limit among the big providers
108
- (Gemini's for inline data). Above it the gateway answers 413 and says to
109
- send http(s) URLs or smaller images; models scale a photo down to about
110
- 1,500-2,000 pixels on its long side anyway. Each image counts 2,500 tokens
111
- in the context check, which errs high: OpenAI's, Anthropic's and Gemini's
112
- models spend up to about 1,600 at their usual detail.
113
- - **Never written down.** In `runs/gateway/calls.jsonl`, the offload log,
114
- learned limits, error text (an upstream may echo the request) and the
115
- dashboard, an image is a placeholder: `[image: data URL, 412 KB]`, or an
116
- http URL without its query string or user:password
117
- (`[image: https://photos.example/chair.jpg?...]`). The gateway never
118
- fetches an image URL; the upstream does.
119
- - **Merged replies.** The merger gets the request as text with those
120
- placeholders, plus the seats' answers. Its job is to reconcile what the
121
- seats said, not to look again, so any model can merge and a disagreement
122
- about the picture is reported, not settled.
123
-
124
- ## The reply
125
-
126
- `choices[0].message.content` is markdown: one section per seat with its call
127
- id, status and time. Beside it, a `bench` field carries the same seats as data,
128
- so a caller need not parse the markdown (OpenAI clients ignore unknown fields;
129
- a streaming client gets it on the first chunk):
130
-
131
- ```json
132
- "bench": {"panel": "p20261017-...", "collection": "bench/core", "shape": "review",
133
- "shape_by": "set on the collection",
134
- "seats": [{"call": "p20261017-....member1", "seat": "member", "alias": "…",
135
- "status": "ok|empty|error", "seconds": 12.3, "answer": "…", "error": ""}],
136
- "verdict": "POST http://127.0.0.1:8123/bench/verdict {…}"}
137
- ```
138
-
139
- **Merged replies (opt-in).** A collection's `reply` is `all` (the default) or
140
- `merged`; a request overrides it with `"bench": {"reply": "merged"}` in the
141
- body, or a `:merged` / `:all` suffix on the model name for clients that can
142
- only pick a name (`bench/reviewers:merged`), and `GET /v1/models` lists each
143
- collection in its other mode too. Merged adds one call after the panel: a
144
- merger writes a single answer saying where the seats agreed, where they
145
- disagreed and who said what, and that becomes `content`. The merger is the
146
- collection's `merger`, or when empty the member with the most useful verdicts
147
- on the request's shape, else the router's primary, else the first member that
148
- fits. It gets its own call id (`<panel>.merger`, in `bench.merger`, the
149
- offload log and the Gateway card) so a verdict can judge the merge; `bench`
150
- still carries every seat. If no merger has the context window for the
151
- request plus every answer, or it fails, the reply is every answer with a
152
- one-line note (`bench.note`). A streaming client gets it as one chunk, like
153
- any reply.
154
-
155
- A verdict is `POST /bench/verdict` with `{"call", "useful": true|false, "note",
156
- "real": [...], "false": [...]}`, `offload.py verdict <call id>`, or the verdict
157
- dialog in the dashboard's Collections tab, under Gateway (click a seat). Sending the same verdict
158
- twice writes nothing.
159
-
160
- ## The decision model's job (optional)
161
-
162
- A collection's members are fixed; only the explorer seats are chosen per
163
- request, and they should go to models suited to the task — no code-completion
164
- model on a design question. A collection may set its **shape** (review, design,
165
- enumeration, …), and then that decides. Left on *automatic*, a decision model
166
- labels the prompt, when one that takes choice questions is onboarded and not ad
167
- hoc; otherwise, or on failure or confidence under 0.6, the shape is `general`.
168
- With no explorer seats to fill, nothing is asked.
169
-
170
- ## Who may call
171
-
172
- | Where it runs | Set | The gateway asks for |
173
- |---|---|---|
174
- | This machine, as installed | nothing | no key: every caller is `local`, any bearer is ignored |
175
- | This machine, one key per app | `BENCHWARMERS_REQUIRE_KEYS=1` | a gateway key; the dashboard stays open |
176
- | LAN, Docker or cloud | `BENCHWARMERS_ADMIN_TOKEN`, `BENCH_BIND`, `BENCHWARMERS_PUBLIC_URL` | a gateway key on every `/v1/...` and `/bench/...`; the dashboard asks for the token |
177
- | A browser app on another origin | `BENCH_ALLOWED_ORIGINS=https://app.example` (comma-separated), plus one of the rows above | as that row says |
178
-
179
- **Gateway keys** are made on Settings, sent as `Authorization: Bearer <key>`,
180
- shown once and kept as a SHA-256. Each has a name, written on every call it
181
- makes (`key` in `calls.jsonl`, "Asked by" on the Gateway card), and a "last
182
- used" on Settings. Optional, set when it is made: the **collections** it may
183
- call (others are refused with 403, and `GET /v1/models` lists only its own)
184
- and **requests a minute** (429 with `Retry-After` past it; counted per
185
- process). Keys start `bwk_`, so a leaked one is recognisable, and Settings
186
- shows each key's last four characters to tell which. A missing, malformed or
187
- unknown key is refused with 401, a message saying which, and
188
- `WWW-Authenticate: Bearer`.
189
-
190
- **CORS** is answered on the gateway's routes only, for an allowed origin
191
- (`BENCH_ALLOWED_ORIGINS`, and `BENCHWARMERS_PUBLIC_URL`'s): the preflight
192
- allows `Authorization`, `Content-Type` and any other plain header names it
193
- asks for (the OpenAI JS SDK sends `X-Stainless-*`), and every reply echoes
194
- the origin with `Vary: Origin`. Never `*`, never credentials: the key travels
195
- in a header, not a cookie. Such an origin may be cross-site; every other
196
- browser request stays same-origin, and the dashboard answers no preflight. A
197
- key in a page's code is visible to whoever opens it, so give a browser app a
198
- key limited to its collection and a rate, or keep the key on the app's server.
199
-
200
- ## Components
201
-
202
- | Part | Job |
203
- |---|---|
204
- | Gateway | `gateway.py`, stdlib server on 127.0.0.1. `GET /v1/models` lists the collections with description, tags and make-up; `POST /v1/chat/completions` runs one, images included. A streaming client gets the finished answer as one chunk. |
205
- | Collections | `metrics/bench_collections.py` reads the two config files and computes explorer seats. |
206
- | Seat router | `route.panel(members=…, explorers=n)` fills explorer seats by staleness for the shape. |
207
- | Shape picker | Optional decision model (above). |
208
- | Call log | `runs/gateway/calls.jsonl`, gitignored: every seat's answer, status, timing and call id. |
209
- | Evidence | Each seat is recorded in `metrics/offloads.jsonl` as an unjudged offload when it answers; `offload.py verdict <call id>` or `POST /bench/verdict` replaces it. |
210
- | Collections tab | The collections and their form, then the gateway's recent requests: every seat's status and verdict, and how many verdicts are owed. Clicking a seat opens its answer and the verdict form. |
211
- | Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily (scheduled task WRBench-Catalog); a free model whose price goes above zero raises a "went paid" alert. |
212
- | Discover tab | `metrics/discover.py`: models.dev and FreeLLMAPI merged into one list of models worth adding, free first, marked by whether you can already reach the provider and whether the model is on the bench (on another provider counts as "also on bench"). |
213
- | Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
214
- | MCP server | `mcp_server.py` (`benchwarmers mcp`): the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`, over HTTP to a running server (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`), so it is a caller like any other. The Claude Code plugin in `plugin/` wraps it with a command and a skill. |
215
-
216
- ## Rules
217
-
218
- - **The bench method only.** No single-model routing.
219
- - **The caller judges.** Only the caller can check an answer against the real
220
- code. Models fail together, so agreement between seats is not a verdict.
221
- - **Every seat is named** in the response, including ones that failed or came
222
- back empty, because who answered is itself evidence about the roster.
223
- - **Free models only**, so rate limits are the constraint; a failed seat is
224
- reported and logged, not retried into the limit.
225
- - **Privacy:** prompts go only to providers the bench already uses.
226
- - **Bind to 127.0.0.1**, as the dashboard does. Elsewhere only with an admin
227
- token set, and then every call carries a gateway key made on the
228
- dashboard's Settings page (`Authorization: Bearer <key>`); see "Who may
229
- call". The dashboard answers the same routes on its own port, so one port
230
- can serve both (README, "Security").
231
-
232
- ## What is and isn't new
233
-
234
- A git-for-me search (2026-10-16) found gateways (mozilla-ai/otari, Gozar,
235
- NadirClaw), councils (karpathy/llm-council, togethercomputer/MoA) and
236
- observability platforms (opik). None serves a curated multi-model collection as
237
- an OpenAI model name with exploration seats, and none closes the loop from a
238
- caller's verdict back into who gets seated next. LiteLLM was rejected: a second
239
- copy of the alias table and a plugin to write bench evidence.
240
-
241
- ## Decisions (2026-10-16)
242
-
243
- - **No council route, for now.** A council would have the members review each
244
- other and one model merge the result into a single answer. That suits a
245
- caller that cannot read several answers; every caller so far can, and the
246
- orchestrator verifies against the code better than a merging model. A merge
247
- also hides who said what, which is the evidence the bench runs on. Revisit
248
- if a single-answer client needs the bench. (Revisited 2026-10-19 for chat
249
- UIs: merged replies, opt-in, with every seat still called, recorded and
250
- returned in `bench`, and the merge told to keep who said what.)
251
- - **Collections replace `bench/panel`.** Every request names a collection;
252
- the bench adds the explorers.
253
- - **No single-model routes**, which also dropped the shadow worker and judge
254
- model from the first draft: they learned from single-model traffic that no
255
- longer passes through the bench.
256
- - **Decision models** are onboarded into `config/decision-models.json` with
257
- the question types they accept, and called through the endpoint they were
258
- picked from. Option descriptions must be given, because bare names mislead
259
- them (Mercury Decide called an enumeration `code` at 0.93).
260
- - **Provider paths differ:** Inception and OpenRouter call the API
261
- `decisions`, Ollama `systemone`. Open WebUI's passthrough appends the path to
262
- the connection's base, so its URL carries `{path}` and onboarding records the
263
- one that answered.
264
-
265
- ## Reviews so far
266
-
267
- - `bench/panel` reviewed `gateway.py`: Mercury Decide picked `review` at 0.99;
268
- two seats found real defects, two found nothing, two agreed on a streaming
269
- hang that was false.
270
- - `bench/core` reviewed the collections change: 3 members + 2 explorers, shape
271
- `review` at 0.97; four seats found real defects (fixed), one came back empty,
272
- and three asserted a duplicate-seat bug the code already prevents.
1
+ # Bench gateway — design
2
+
3
+ Status: **built** (2026-10-16): `python gateway.py`, then point an OpenAI client
4
+ at `http://127.0.0.1:8123/v1` with a collection as the model, e.g. `bench/core`.
5
+ Since 2026-10-23 it also speaks Anthropic Messages, OpenAI Responses, Ollama,
6
+ Gemini and OpenAI's legacy completions, and streams each seat as it finishes
7
+ (Other API formats, Streaming).
8
+ Diagram: `.archify/architecture-bench-gateway-20261016-182500/bench-gateway.html`.
9
+
10
+ ## The idea in one paragraph
11
+
12
+ The bench gets its own OpenAI-compatible endpoint whose only job is **the bench
13
+ method**: one prompt, several different models, every answer back, every
14
+ answer logged. You define **collections** — named groups of models such as
15
+ `core` or `reviewers` — on the bench. A caller asks for `bench/<name>` and the
16
+ prompt goes to every member of that collection plus one or two **explorer**
17
+ seats the bench fills by staleness, so the roster keeps getting evidence even
18
+ when the same collection is called every day. Each answer comes back labelled
19
+ by model and seat with a call id; the caller checks the answers and records a
20
+ verdict by call id, which lands in the evidence `route.py` reads. Using the
21
+ bench and measuring it stay the same act, without anyone retyping an offload.
22
+
23
+ Single-model calls are **not** the bench's job: chat and coding agents keep
24
+ calling the upstream relay directly. The relay stays the engine; the bench
25
+ gateway sits beside it for the multi-model work.
26
+
27
+ ## Collections and explorer seats
28
+
29
+ ```
30
+ explorers = min(max_explorers, max_calls - members) defaults: 2 and 5
31
+ ```
32
+
33
+ | Members | Explorers | Calls |
34
+ |---|---|---|
35
+ | 1 | 2 | 3 |
36
+ | 2 | 2 | 4 |
37
+ | 3 | 2 | 5 |
38
+ | 4 | 1 | 5 |
39
+ | 5 | 0 | 5 |
40
+
41
+ Two to four members is recommended. Explorers are never a member or a member's
42
+ model family. Collections live in `config/collections.json`, the limits in
43
+ `config/gateway.json`; both are edited in the dashboard's
44
+ **Collections** tab and every change is logged in `config/changes.jsonl`.
45
+
46
+ **Naming:** `bench/<name>`, lower-case letters, digits and `-`. The name says
47
+ what the collection is *for* (`reviewers`, `design`), not who is in it, because
48
+ members change. The `bench/` prefix keeps collections apart from real model ids
49
+ in clients that merge model lists.
50
+
51
+ **Choosing a collection by task:** each collection has a `description` (what
52
+ it is for, up to 300 characters) and `tags` (up to 8 lower-case words).
53
+ `GET /v1/models` returns them with the members, shape and explorer count, so an
54
+ orchestrator can list the collections and pick the one that fits before it
55
+ calls. Write the description for that reader.
56
+
57
+ **No default collection.** A request that names no collection, or one that
58
+ does not exist, is refused (400 / 404) with every collection's description and
59
+ tags in the message, so a caller that could not choose learns how to. A
60
+ `bench/auto` that guessed for it was considered and rejected: the caller knows
61
+ the task, and a guess would run the wrong models silently.
62
+
63
+ ## Prompt size
64
+
65
+ The gateway estimates the prompt (system text included) at characters / 4 plus
66
+ 10%, which errs high. If that is larger than any **member's** context window,
67
+ the request is refused with **413** naming each member that is too small and
68
+ its window, so the caller can trim the prompt or pick another collection;
69
+ `GET /v1/models` gives each collection's `together.context_window` (the smallest
70
+ known among its members) to check first. Explorer seats skip models too small
71
+ for the prompt, as well as retiring and rate-limited ones.
72
+
73
+ Context windows come from `metrics/catalog.py`: your overrides in the model
74
+ popup, then windows learned from real "maximum context length" errors (only
75
+ ever lower), then the daily refresh from each model's own endpoint listing,
76
+ models.dev and FreeLLMAPI. A model with no known window is never refused.
77
+ Each image adds 2,500 tokens to the estimate (see Images).
78
+
79
+ ## Images
80
+
81
+ A request whose messages carry OpenAI `image_url` parts, with an `https://`
82
+ URL or a `data:image/...;base64,` URL, goes to every seat with its messages
83
+ exactly as sent:
84
+
85
+ ```json
86
+ {"model": "bench/vision", "messages": [{"role": "user", "content": [
87
+ {"type": "text", "text": "What is this chair, and what might it fetch?"},
88
+ {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}]}]}
89
+ ```
90
+
91
+ - **Through the built-in client**, whatever `BENCH_CLIENT` says: the `llm`
92
+ CLI takes a prompt, not inline images. A text-only request takes the same
93
+ path as before.
94
+ - **Only seats that can see.** A model sits only if the catalog says it takes
95
+ images: its input types from its endpoint's listing, models.dev or
96
+ OpenRouter, or **Vision / OCR** ticked in its model popup. A member that
97
+ cannot, or that the catalog knows nothing about, is not asked and is listed
98
+ in `bench.skipped` with why (`{"seat", "alias", "why"}`; a grey chip on the
99
+ Gateway card); so is a `with` nomination. Explorer seats go only to models
100
+ that take images. With no seat that can see, the request is refused (400)
101
+ rather than answered blind. Unknown counts as cannot because a model that
102
+ cannot see answers anyway, describing a photo it never got, and the popup
103
+ fixes it in one click. A model reached through the Responses API (a ChatGPT
104
+ plan) gets no images yet.
105
+ - **A vision collection** is members that take images; `GET /v1/models` lists
106
+ `vision` in its `together.capabilities` when every member does. An image
107
+ request on a collection with no shape takes the shape `vision` (general,
108
+ light and vision models), so verdicts on image work are counted apart.
109
+ - **Vision specialists.** A model whose role is `vision` (set in its popup) is
110
+ good at reading images and weak at text-only work, such as an OCR or
111
+ document model. Only the `vision` shape admits it, and as a collection
112
+ member it sits only when the request carries images; on a text request it
113
+ is listed in `bench.skipped` and its seat goes to the next member or an
114
+ explorer. Named in `with`, it sits anyway. A model that reads images and
115
+ does everything else well stays `general`.
116
+ - **Size.** The body cap is 20 MB: a photo as a data URL is 4/3 of its file,
117
+ and 20 MB is the tightest whole-request limit among the big providers
118
+ (Gemini's for inline data). Above it the gateway answers 413 and says to
119
+ send http(s) URLs or smaller images; models scale a photo down to about
120
+ 1,500-2,000 pixels on its long side anyway. Each image counts 2,500 tokens
121
+ in the context check, which errs high: OpenAI's, Anthropic's and Gemini's
122
+ models spend up to about 1,600 at their usual detail.
123
+ - **Never written down.** In `runs/gateway/calls.jsonl`, the offload log,
124
+ learned limits, error text (an upstream may echo the request) and the
125
+ dashboard, an image is a placeholder: `[image: data URL, 412 KB]`, or an
126
+ http URL without its query string or user:password
127
+ (`[image: https://photos.example/chair.jpg?...]`). The gateway never
128
+ fetches an image URL; the upstream does.
129
+ - **Merged replies.** The merger gets the request as text with those
130
+ placeholders, plus the seats' answers. Its job is to reconcile what the
131
+ seats said, not to look again, so any model can merge and a disagreement
132
+ about the picture is reported, not settled.
133
+
134
+ ## The reply
135
+
136
+ `choices[0].message.content` is markdown: one section per seat with its call
137
+ id, status and time. Beside it, a `bench` field carries the same seats as data,
138
+ so a caller need not parse the markdown (OpenAI clients ignore unknown fields;
139
+ a streaming client gets it on the last chunk, the one with the finish_reason):
140
+
141
+ ```json
142
+ "bench": {"panel": "p20261017-...", "collection": "bench/core", "shape": "review",
143
+ "shape_by": "set on the collection",
144
+ "seats": [{"call": "p20261017-....member1", "seat": "member", "alias": "…",
145
+ "status": "ok|empty|error", "seconds": 12.3, "answer": "…", "error": ""}],
146
+ "verdict": "POST http://127.0.0.1:8123/bench/verdict {…}"}
147
+ ```
148
+
149
+ **Merged replies (opt-in).** A collection's `reply` is `all` (the default) or
150
+ `merged`; a request overrides it with `"bench": {"reply": "merged"}` in the
151
+ body, or a `:merged` / `:all` suffix on the model name for clients that can
152
+ only pick a name (`bench/reviewers:merged`), and `GET /v1/models` lists each
153
+ collection in its other mode too. Merged adds one call after the panel: a
154
+ merger writes a single answer saying where the seats agreed, where they
155
+ disagreed and who said what, and that becomes `content`. The merger is the
156
+ collection's `merger`, or when empty the member with the most useful verdicts
157
+ on the request's shape, else the router's primary, else the first member that
158
+ fits. It gets its own call id (`<panel>.merger`, in `bench.merger`, the
159
+ offload log and the Gateway card) so a verdict can judge the merge; `bench`
160
+ still carries every seat. If no merger has the context window for the
161
+ request plus every answer, or it fails, the reply is every answer with a
162
+ one-line note (`bench.note`). A streaming client gets the merged answer in one
163
+ piece once the merge is done (Streaming).
164
+
165
+ A verdict is `POST /bench/verdict` with `{"call", "useful": true|false, "note",
166
+ "real": [...], "false": [...]}`, `offload.py verdict <call id>`, or the verdict
167
+ dialog in the dashboard's Collections tab, under Gateway (click a seat). Sending the same verdict
168
+ twice writes nothing.
169
+
170
+ ## Streaming
171
+
172
+ With `"stream": true` (or the format's own way of asking, below):
173
+
174
+ - **The 200 goes at once**: as soon as the panel is seated, and at the
175
+ latest after a second (the shape picker can take longer). A refusal raised
176
+ before then (too large for a member, an image no seat can see, no seat
177
+ free) keeps its status and the format's error body, as without streaming.
178
+ - **Keep-alives** go every 10 seconds while the seats think, in the format's
179
+ own form: an SSE comment for the OpenAI formats, Anthropic's `ping`, a
180
+ chunk with no text for Ollama and Gemini (their readers have no comments).
181
+ A client or proxy with an idle timeout keeps the connection.
182
+ - **Every answer** (`reply` all): the heading, then each seat's section the
183
+ moment that seat finishes, then the verdict lines. The pieces are the same
184
+ as the non-streamed reply's, with the seats in the order they finished
185
+ rather than seat order.
186
+ - **Merged**: the merged answer in one piece when the merge is done (or
187
+ every answer with its note, if the merge fails). Token by token waits for
188
+ the built-in client's streaming.
189
+ - **An error after the 200** goes into the stream in the format's own error
190
+ shape (the OpenAI SDKs' `{"error": ...}` chunk, Anthropic's `error` event,
191
+ Responses' `error` and `response.failed`, Ollama's `{"error": ...}` line,
192
+ Gemini's error object), so a client raises rather than hangs.
193
+ - **The `bench` field** rides on the last event where the format has room
194
+ (the table below). It is in the JSON on the wire; an SDK that parses into
195
+ fixed types may drop it (the Anthropic SDK's accumulated final message and
196
+ the `ollama` library's replies do; read the raw event, or the call ids,
197
+ which every reply's text carries). A caller that hangs up does not stop
198
+ the panel: every seat still answers and is recorded.
199
+
200
+ ## Other API formats
201
+
202
+ Each is a thin adapter around the same panel (`metrics/formats.py`): the
203
+ request becomes OpenAI-style messages, the panel runs exactly as on
204
+ `/v1/chat/completions` (collections, explorers, size check, images, keys,
205
+ scope, rate caps, CORS, the call log), and the reply goes back in the
206
+ format's own shape. The model field is the collection: `bench/core`, or
207
+ `bench/core:merged` for one merged answer. Clients that pick from a list
208
+ (`/api/tags`, `/v1beta/models`, `/v1/models`) see every collection in both
209
+ reply modes.
210
+
211
+ | Format | Routes | Streams with | `bench` field | Known clients |
212
+ |---|---|---|---|---|
213
+ | OpenAI chat completions | `POST /v1/chat/completions`, `GET /v1/models` | `"stream": true` (SSE) | in the reply; the last chunk | OpenAI SDKs, Open WebUI's OpenAI connection, most chat apps |
214
+ | OpenAI Responses | `POST /v1/responses` | `"stream": true` (typed events) | in the response; `response.completed` | OpenAI SDKs' `responses.create`, Responses-only clients |
215
+ | OpenAI legacy completions | `POST /v1/completions` | `"stream": true` (SSE) | in the reply; the last chunk | older tools that send a `prompt` |
216
+ | Anthropic Messages | `POST /v1/messages`, `POST /v1/messages/count_tokens` | `"stream": true` (SSE events) | in the message; `message_delta` | Anthropic SDKs with `base_url`, tools built on them |
217
+ | Ollama | `POST /api/chat`, `POST /api/generate`, `GET /api/tags`, `GET /api/version`, `POST /api/show` | on unless `"stream": false` (NDJSON) | in the reply; the `done` line | Open WebUI's Ollama connection, the `ollama` libraries, Ollama-only apps |
218
+ | Gemini | `POST /v1beta/models/<collection>:generateContent`, `:streamGenerateContent` (`?alt=sse`, else a JSON array), `:countTokens`, `GET /v1beta/models` | the stream route | **headers**: `X-Bench-Panel`, `X-Bench-Seats` (call, seat, alias, status); none when streamed | Google GenAI SDKs with `base_url`, Gemini REST |
219
+
220
+ The base URL is the server itself (`http://127.0.0.1:8122` on the dashboard's
221
+ port, `:8123` for `gateway.py`): the Anthropic SDK and Google's add their own
222
+ `/v1/...` and `/v1beta/...`, Ollama clients their `/api/...`; OpenAI clients
223
+ take `.../v1`.
224
+
225
+ ```bash
226
+ curl http://127.0.0.1:8122/v1/messages -H "Content-Type: application/json" \
227
+ -d '{"model": "bench/core", "max_tokens": 1024, "messages": [{"role": "user", "content": "Where can this return a stale value? ..."}]}'
228
+ curl http://127.0.0.1:8122/v1/responses -H "Content-Type: application/json" \
229
+ -d '{"model": "bench/core", "input": "Where can this return a stale value? ..."}'
230
+ curl http://127.0.0.1:8122/v1/completions -H "Content-Type: application/json" \
231
+ -d '{"model": "bench/core", "prompt": "Where can this return a stale value? ..."}'
232
+ curl http://127.0.0.1:8122/api/chat -H "Content-Type: application/json" \
233
+ -d '{"model": "bench/core", "stream": false, "messages": [{"role": "user", "content": "Where can this return a stale value? ..."}]}'
234
+ curl "http://127.0.0.1:8122/v1beta/models/bench/core:streamGenerateContent?alt=sse" -H "Content-Type: application/json" \
235
+ -d '{"contents": [{"parts": [{"text": "Where can this return a stale value? ..."}]}]}'
236
+ ```
237
+
238
+ Every POST needs `Content-Type: application/json`, as on chat completions
239
+ (it is what keeps a web page's plain form out).
240
+
241
+ ```python
242
+ import anthropic
243
+ bench = anthropic.Anthropic(base_url="http://127.0.0.1:8122", api_key="local") # or a gateway key
244
+ with bench.messages.stream(model="bench/core", max_tokens=1024,
245
+ messages=[{"role": "user", "content": "..."}]) as s:
246
+ for text in s.text_stream:
247
+ print(text, end="") # each seat's section as that seat finishes
248
+
249
+ from google import genai
250
+ from google.genai import types
251
+ gem = genai.Client(api_key="local", http_options=types.HttpOptions(base_url="http://127.0.0.1:8122"))
252
+ print(gem.models.generate_content(model="bench/core:merged", contents="...").text)
253
+ ```
254
+
255
+ In **Open WebUI**, add `http://127.0.0.1:8122` as an Ollama connection (or
256
+ `.../v1` as an OpenAI one): the collections appear as models.
257
+
258
+ **Keys.** Where keys are required, a gateway key goes as `Authorization:
259
+ Bearer`, or as `x-api-key` (the Anthropic SDK's `api_key`), `x-goog-api-key`
260
+ (the Google SDKs' `api_key`), or `?key=` on a Gemini route only (Google's REST
261
+ examples). The first one sent is judged. The access log never records a
262
+ query string or a key; neither does http.server's own error line.
263
+
264
+ **Images** in each format's shape become `image_url` parts, so only seats that
265
+ can see sit (Images): Anthropic `image` blocks (base64 or url), Responses
266
+ `input_image`, Ollama's `images` (base64, its type read from its first
267
+ bytes), Gemini `inlineData` and `fileData` at an http(s) URL.
268
+
269
+ **Refused, not dropped** (400, with why), because a client waiting for what
270
+ it asked would hang or loop on a panel's text:
271
+
272
+ - **Tools and agents are not supported yet.** A request carrying tools, or a
273
+ conversation holding tool calls or tool results, in any format (chat
274
+ completions included), is refused with a message saying a route mode is
275
+ coming: one member proxied with tools and streaming, for agent clients
276
+ such as Claude Code, Codex or opencode, with background shadow seats as an
277
+ opt-in after that. An empty `tools` list is no tools.
278
+ - a PDF, audio or other non-image part; an image as an uploaded file id
279
+ (Anthropic `file`, Responses `file_id`, Gemini's Files API);
280
+ - `previous_response_id` or a stored `conversation` on Responses: nothing is
281
+ stored (`store` is answered `false`), so send the whole conversation;
282
+ - Ollama's `context` (a model's own token state): use `/api/chat`;
283
+ - more than one prompt on legacy completions.
284
+
285
+ **Ignored**: sampling options (`max_tokens`, `temperature`, `top_p`, `stop`
286
+ and their native names) are read but each seat keeps its own call options;
287
+ structured-output settings (Ollama `format`, Gemini `responseSchema`,
288
+ Responses `text.format`), since a panel answers in markdown; a model's own
289
+ reasoning from an earlier turn (Anthropic thinking blocks, Responses
290
+ reasoning items, Gemini thought parts).
291
+
292
+ **Counts and durations.** `usage` and the token-count routes
293
+ (`/v1/messages/count_tokens`, Gemini `:countTokens`) are the size check's
294
+ estimate, characters / 4 plus 10% and 2,500 a picture, with no model called.
295
+ Ollama's durations are real. `/api/version` reports the Ollama API version
296
+ whose shapes these follow (0.12.0), so clients send what it reads.
297
+
298
+ **On the dashboard's port**, Ollama's five `/api/` routes are the gateway's
299
+ (a gateway key, never the login cookie); the dashboard's own `/api/...`
300
+ routes never use those names.
301
+
302
+ ## The decision model's job (optional)
303
+
304
+ A collection's members are fixed; only the explorer seats are chosen per
305
+ request, and they should go to models suited to the task — no code-completion
306
+ model on a design question. A collection may set its **shape** (review, design,
307
+ enumeration, …), and then that decides. Left on *automatic*, a decision model
308
+ labels the prompt, when one that takes choice questions is onboarded and not ad
309
+ hoc; otherwise, or on failure or confidence under 0.6, the shape is `general`.
310
+ With no explorer seats to fill, nothing is asked.
311
+
312
+ ## Who may call
313
+
314
+ | Where it runs | Set | The gateway asks for |
315
+ |---|---|---|
316
+ | This machine, as installed | nothing | no key: every caller is `local`, any bearer is ignored |
317
+ | This machine, one key per app | `BENCHWARMERS_REQUIRE_KEYS=1` | a gateway key; the dashboard stays open |
318
+ | LAN, Docker or cloud | `BENCHWARMERS_ADMIN_TOKEN`, `BENCH_BIND`, `BENCHWARMERS_PUBLIC_URL` | a gateway key on every `/v1/...` and `/bench/...`; the dashboard asks for the token |
319
+ | A browser app on another origin | `BENCH_ALLOWED_ORIGINS=https://app.example` (comma-separated), plus one of the rows above | as that row says |
320
+
321
+ **Gateway keys** are made on Settings, sent as `Authorization: Bearer <key>`,
322
+ shown once and kept as a SHA-256. Each has a name, written on every call it
323
+ makes (`key` in `calls.jsonl`, "Asked by" on the Gateway card), and a "last
324
+ used" on Settings. Optional, set when it is made: the **collections** it may
325
+ call (others are refused with 403, and `GET /v1/models` lists only its own)
326
+ and **requests a minute** (429 with `Retry-After` past it; counted per
327
+ process). Keys start `bwk_`, so a leaked one is recognisable, and Settings
328
+ shows each key's last four characters to tell which. A missing, malformed or
329
+ unknown key is refused with 401, a message saying which, and
330
+ `WWW-Authenticate: Bearer`.
331
+
332
+ **CORS** is answered on the gateway's routes only, for an allowed origin
333
+ (`BENCH_ALLOWED_ORIGINS`, and `BENCHWARMERS_PUBLIC_URL`'s): the preflight
334
+ allows `Authorization`, `Content-Type` and any other plain header names it
335
+ asks for (the OpenAI JS SDK sends `X-Stainless-*`), and every reply echoes
336
+ the origin with `Vary: Origin`. Never `*`, never credentials: the key travels
337
+ in a header, not a cookie. Such an origin may be cross-site; every other
338
+ browser request stays same-origin, and the dashboard answers no preflight. A
339
+ key in a page's code is visible to whoever opens it, so give a browser app a
340
+ key limited to its collection and a rate, or keep the key on the app's server.
341
+
342
+ ## Components
343
+
344
+ | Part | Job |
345
+ |---|---|
346
+ | Gateway | `gateway.py`, stdlib server on 127.0.0.1. `GET /v1/models` lists the collections with description, tags and make-up; `POST /v1/chat/completions` runs one, images included, streamed seat by seat when asked. |
347
+ | Formats | `metrics/formats.py`: Anthropic Messages, Responses, legacy completions, Ollama and Gemini, each translated to and from OpenAI messages (Other API formats); the outbound half is for the built-in client to call those APIs natively, not wired yet. |
348
+ | Collections | `metrics/bench_collections.py` reads the two config files and computes explorer seats. |
349
+ | Seat router | `route.panel(members=…, explorers=n)` fills explorer seats by staleness for the shape. |
350
+ | Shape picker | Optional decision model (above). |
351
+ | Call log | `runs/gateway/calls.jsonl`, gitignored: every seat's answer, status, timing and call id. |
352
+ | Evidence | Each seat is recorded in `metrics/offloads.jsonl` as an unjudged offload when it answers; `offload.py verdict <call id>` or `POST /bench/verdict` replaces it. |
353
+ | Collections tab | The collections and their form, then the gateway's recent requests: every seat's status and verdict, and how many verdicts are owed. Clicking a seat opens its answer and the verdict form. |
354
+ | Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily (scheduled task WRBench-Catalog); a free model whose price goes above zero raises a "went paid" alert. |
355
+ | Discover tab | `metrics/discover.py`: models.dev and FreeLLMAPI merged into one list of models worth adding, free first, marked by whether you can already reach the provider and whether the model is on the bench (on another provider counts as "also on bench"). |
356
+ | Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
357
+ | MCP server | `mcp_server.py` (`benchwarmers mcp`): the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`, over HTTP to a running server (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`), so it is a caller like any other. The Claude Code plugin in `plugin/` wraps it with a command and a skill. |
358
+
359
+ ## Rules
360
+
361
+ - **The bench method only.** No single-model routing.
362
+ - **The caller judges.** Only the caller can check an answer against the real
363
+ code. Models fail together, so agreement between seats is not a verdict.
364
+ - **Every seat is named** in the response, including ones that failed or came
365
+ back empty, because who answered is itself evidence about the roster.
366
+ - **Free models only**, so rate limits are the constraint; a failed seat is
367
+ reported and logged, not retried into the limit.
368
+ - **Privacy:** prompts go only to providers the bench already uses.
369
+ - **Bind to 127.0.0.1**, as the dashboard does. Elsewhere only with an admin
370
+ token set, and then every call carries a gateway key made on the
371
+ dashboard's Settings page (`Authorization: Bearer <key>`); see "Who may
372
+ call". The dashboard answers the same routes on its own port, so one port
373
+ can serve both (README, "Security").
374
+
375
+ ## What is and isn't new
376
+
377
+ A git-for-me search (2026-10-16) found gateways (mozilla-ai/otari, Gozar,
378
+ NadirClaw), councils (karpathy/llm-council, togethercomputer/MoA) and
379
+ observability platforms (opik). None serves a curated multi-model collection as
380
+ an OpenAI model name with exploration seats, and none closes the loop from a
381
+ caller's verdict back into who gets seated next. LiteLLM was rejected: a second
382
+ copy of the alias table and a plugin to write bench evidence.
383
+
384
+ ## Decisions (2026-10-16)
385
+
386
+ - **No council route, for now.** A council would have the members review each
387
+ other and one model merge the result into a single answer. That suits a
388
+ caller that cannot read several answers; every caller so far can, and the
389
+ orchestrator verifies against the code better than a merging model. A merge
390
+ also hides who said what, which is the evidence the bench runs on. Revisit
391
+ if a single-answer client needs the bench. (Revisited 2026-10-19 for chat
392
+ UIs: merged replies, opt-in, with every seat still called, recorded and
393
+ returned in `bench`, and the merge told to keep who said what.)
394
+ - **Collections replace `bench/panel`.** Every request names a collection;
395
+ the bench adds the explorers.
396
+ - **No single-model routes**, which also dropped the shadow worker and judge
397
+ model from the first draft: they learned from single-model traffic that no
398
+ longer passes through the bench.
399
+ - **Decision models** are onboarded into `config/decision-models.json` with
400
+ the question types they accept, and called through the endpoint they were
401
+ picked from. Option descriptions must be given, because bare names mislead
402
+ them (Mercury Decide called an enumeration `code` at 0.93).
403
+ - **Provider paths differ:** Inception and OpenRouter call the API
404
+ `decisions`, Ollama `systemone`. Open WebUI's passthrough appends the path to
405
+ the connection's base, so its URL carries `{path}` and onboarding records the
406
+ one that answered.
407
+
408
+ ## Reviews so far
409
+
410
+ - `bench/panel` reviewed `gateway.py`: Mercury Decide picked `review` at 0.99;
411
+ two seats found real defects, two found nothing, two agreed on a streaming
412
+ hang that was false.
413
+ - `bench/core` reviewed the collections change: 3 members + 2 explorers, shape
414
+ `review` at 0.97; four seats found real defects (fixed), one came back empty,
415
+ and three asserted a duplicate-seat bug the code already prevents.