@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +295 -295
  22. package/metrics/client.py +394 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -579
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -65
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -106
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -81
  62. package/scripts/test_mcp.py +178 -178
  63. package/scripts/test_merged.py +214 -214
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -438
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3764
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1042
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -425
  93. package/ui/bench.js +1756 -1576
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
package/GATEWAY.md CHANGED
@@ -1,272 +1,272 @@
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
+ 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.
package/NOTICE CHANGED
@@ -1,28 +1,28 @@
1
- Benchwarmers, by Wendell Research
2
- Copyright (c) 2026 Wendell Research. Licensed under the MIT License (LICENSE).
3
-
4
- This repository also contains material from others, under their own licences:
5
-
6
- Real-bug tasks (suite/tasks/rebench-*) are drawn from the nebius/SWE-rebench-V2 dataset
7
- on Hugging Face (CC-BY-4.0). Each task holds code from the project it names,
8
- under that project's licence:
9
-
10
- matchms/matchms (Apache-2.0)
11
- https://github.com/matchms/matchms/pull/809
12
- more-itertools/more-itertools (MIT)
13
- https://github.com/more-itertools/more-itertools/pull/1028
14
- narwhals-dev/narwhals (MIT)
15
- https://github.com/narwhals-dev/narwhals/pull/2864
16
- QuantEcon/QuantEcon.py (MIT)
17
- https://github.com/QuantEcon/QuantEcon.py/pull/769
18
- scrapy/scrapy (BSD-3-Clause)
19
- https://github.com/scrapy/scrapy/pull/6867
20
-
21
- Fonts (ui/fonts): Chivo and Chivo Mono, Copyright The Chivo Project Authors
22
- (https://github.com/Omnibus-Type/Chivo), under the SIL Open Font License 1.1
23
- (ui/fonts/OFL-*.txt).
24
-
25
- The Wendell Research mascot (ui/mascot.png, drawn at centre court in the
26
- Hardwood theme) is a registered trademark of Wendell Research. It is not
27
- covered by the MIT License: a fork may keep it in place unchanged, but should
28
- not use it to name or brand another product.
1
+ Benchwarmers, by Wendell Research
2
+ Copyright (c) 2026 Wendell Research. Licensed under the MIT License (LICENSE).
3
+
4
+ This repository also contains material from others, under their own licences:
5
+
6
+ Real-bug tasks (suite/tasks/rebench-*) are drawn from the nebius/SWE-rebench-V2 dataset
7
+ on Hugging Face (CC-BY-4.0). Each task holds code from the project it names,
8
+ under that project's licence:
9
+
10
+ matchms/matchms (Apache-2.0)
11
+ https://github.com/matchms/matchms/pull/809
12
+ more-itertools/more-itertools (MIT)
13
+ https://github.com/more-itertools/more-itertools/pull/1028
14
+ narwhals-dev/narwhals (MIT)
15
+ https://github.com/narwhals-dev/narwhals/pull/2864
16
+ QuantEcon/QuantEcon.py (MIT)
17
+ https://github.com/QuantEcon/QuantEcon.py/pull/769
18
+ scrapy/scrapy (BSD-3-Clause)
19
+ https://github.com/scrapy/scrapy/pull/6867
20
+
21
+ Fonts (ui/fonts): Chivo and Chivo Mono, Copyright The Chivo Project Authors
22
+ (https://github.com/Omnibus-Type/Chivo), under the SIL Open Font License 1.1
23
+ (ui/fonts/OFL-*.txt).
24
+
25
+ The Wendell Research mascot (ui/mascot.png, drawn at centre court in the
26
+ Hardwood theme) is a registered trademark of Wendell Research. It is not
27
+ covered by the MIT License: a fork may keep it in place unchanged, but should
28
+ not use it to name or brand another product.