@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.
- package/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
34
|
-
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
- **
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
that
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
##
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
the
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
##
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
|
212
|
-
|
|
213
|
-
|
|
|
214
|
-
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
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.
|