@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.0
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 +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +170 -44
- package/ui/bench.js +6 -3
package/AGENTS.md
CHANGED
|
@@ -105,6 +105,9 @@ Any OpenAI-compatible client asks for the model `bench/<collection>`:
|
|
|
105
105
|
-d '{"model": "bench/<collection>", "messages": [{"role": "user", "content": "..."}]}'
|
|
106
106
|
|
|
107
107
|
Every seat's answer comes back, plus a `bench` field with each seat's call id.
|
|
108
|
+
Clients of the Anthropic, Ollama, Gemini and Responses APIs work too, with
|
|
109
|
+
the server's address as their base URL (GATEWAY.md, "Other API formats");
|
|
110
|
+
requests carrying tools are refused, since a panel answers with text.
|
|
108
111
|
For a chat window that shows one answer, ask for `bench/<collection>:merged`
|
|
109
112
|
(or set the collection's reply to merged): a merger model writes one answer
|
|
110
113
|
saying where the seats agreed and disagreed, and every seat is still in
|
|
@@ -133,7 +136,9 @@ browser app on another origin also needs `BENCH_ALLOWED_ORIGINS`. Check the
|
|
|
133
136
|
result on Settings, Server and access, which shows each of these as it stands
|
|
134
137
|
(never a secret's value). In Docker: put the token and the `BENCHWARMERS_KEY_<NAME>` variables in
|
|
135
138
|
a `.env` beside `compose.yaml`, then `docker compose up -d`; the dashboard and
|
|
136
|
-
gateway share one port and the data folder is a volume.
|
|
139
|
+
gateway share one port and the data folder is a volume. For a hosting platform
|
|
140
|
+
(Fly.io, Railway, Render) or a VPS with Caddy, follow [HOSTING.md](HOSTING.md)
|
|
141
|
+
and its templates under `deploy/`.
|
|
137
142
|
|
|
138
143
|
## When something looks wrong
|
|
139
144
|
|
package/GATEWAY.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Status: **built** (2026-10-16): `python gateway.py`, then point an OpenAI client
|
|
4
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).
|
|
5
8
|
Diagram: `.archify/architecture-bench-gateway-20261016-182500/bench-gateway.html`.
|
|
6
9
|
|
|
7
10
|
## The idea in one paragraph
|
|
@@ -101,8 +104,15 @@ exactly as sent:
|
|
|
101
104
|
plan) gets no images yet.
|
|
102
105
|
- **A vision collection** is members that take images; `GET /v1/models` lists
|
|
103
106
|
`vision` in its `together.capabilities` when every member does. An image
|
|
104
|
-
request on a collection with no shape takes the shape `vision` (general
|
|
105
|
-
light models), so verdicts on image work are counted apart.
|
|
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`.
|
|
106
116
|
- **Size.** The body cap is 20 MB: a photo as a data URL is 4/3 of its file,
|
|
107
117
|
and 20 MB is the tightest whole-request limit among the big providers
|
|
108
118
|
(Gemini's for inline data). Above it the gateway answers 413 and says to
|
|
@@ -126,7 +136,7 @@ exactly as sent:
|
|
|
126
136
|
`choices[0].message.content` is markdown: one section per seat with its call
|
|
127
137
|
id, status and time. Beside it, a `bench` field carries the same seats as data,
|
|
128
138
|
so a caller need not parse the markdown (OpenAI clients ignore unknown fields;
|
|
129
|
-
a streaming client gets it on the
|
|
139
|
+
a streaming client gets it on the last chunk, the one with the finish_reason):
|
|
130
140
|
|
|
131
141
|
```json
|
|
132
142
|
"bench": {"panel": "p20261017-...", "collection": "bench/core", "shape": "review",
|
|
@@ -149,14 +159,303 @@ fits. It gets its own call id (`<panel>.merger`, in `bench.merger`, the
|
|
|
149
159
|
offload log and the Gateway card) so a verdict can judge the merge; `bench`
|
|
150
160
|
still carries every seat. If no merger has the context window for the
|
|
151
161
|
request plus every answer, or it fails, the reply is every answer with a
|
|
152
|
-
one-line note (`bench.note`). A streaming client gets
|
|
153
|
-
|
|
162
|
+
one-line note (`bench.note`). A streaming client gets the merged answer in one
|
|
163
|
+
piece once the merge is done (Streaming).
|
|
154
164
|
|
|
155
165
|
A verdict is `POST /bench/verdict` with `{"call", "useful": true|false, "note",
|
|
156
166
|
"real": [...], "false": [...]}`, `offload.py verdict <call id>`, or the verdict
|
|
157
167
|
dialog in the dashboard's Collections tab, under Gateway (click a seat). Sending the same verdict
|
|
158
168
|
twice writes nothing.
|
|
159
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 all three
|
|
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, in a panel.** A request carrying tools, or a conversation
|
|
273
|
+
holding tool calls or tool results, in any format, is refused by a panel
|
|
274
|
+
(`all` or `merged`) with a message pointing at route mode (next section),
|
|
275
|
+
which takes them. An empty `tools` list is no tools.
|
|
276
|
+
- a tool only its own vendor runs (Anthropic's server tools such as
|
|
277
|
+
`web_search_20250305` and their result blocks, OpenAI's hosted tools such
|
|
278
|
+
as `web_search`, Gemini's Google Search, URL context and code execution),
|
|
279
|
+
in route mode too: no other model can run them;
|
|
280
|
+
- the legacy `functions` / `function_call` fields: send `tools`;
|
|
281
|
+
- a PDF, audio or other non-image part; an image as an uploaded file id
|
|
282
|
+
(Anthropic `file`, Responses `file_id`, Gemini's Files API);
|
|
283
|
+
- `previous_response_id` or a stored `conversation` on Responses: nothing is
|
|
284
|
+
stored (`store` is answered `false`), so send the whole conversation;
|
|
285
|
+
- Ollama's `context` (a model's own token state): use `/api/chat`;
|
|
286
|
+
- more than one prompt on legacy completions.
|
|
287
|
+
|
|
288
|
+
**Ignored by a panel**: sampling options (`max_tokens`, `temperature`,
|
|
289
|
+
`top_p`, `stop` and their native names) are read but each seat keeps its own
|
|
290
|
+
call options; structured-output settings (Ollama `format`, Gemini
|
|
291
|
+
`responseSchema`, Responses `text.format`), since a panel answers in
|
|
292
|
+
markdown. Route mode passes both on. **Ignored everywhere**: a model's own
|
|
293
|
+
reasoning from an earlier turn (Anthropic thinking blocks, Responses
|
|
294
|
+
reasoning items, Gemini thought parts), and native parameters with no
|
|
295
|
+
OpenAI counterpart (Anthropic's `top_k`, `thinking`, `metadata`).
|
|
296
|
+
|
|
297
|
+
## Route mode (agent clients)
|
|
298
|
+
|
|
299
|
+
A panel answers with several models' text; an agent needs one model that
|
|
300
|
+
calls its tools. **Route mode** is that: no panel, one member of the
|
|
301
|
+
collection gets the request as sent — messages, tool calls and results,
|
|
302
|
+
`tools`, `tool_choice`, `parallel_tool_calls`, `response_format`,
|
|
303
|
+
temperature and the rest, images — and its answer is relayed in the
|
|
304
|
+
request's own format, streamed or not. Ask for it with `:route` on the
|
|
305
|
+
model (`bench/core:route`), `"bench": {"reply": "route"}`, or set a
|
|
306
|
+
collection's reply to *route* in the Collections dialog. `GET /v1/models`
|
|
307
|
+
(and `/api/tags`, `/v1beta/models`) lists every collection in all three
|
|
308
|
+
modes, so an agent's model picker can choose `bench/core:route`.
|
|
309
|
+
|
|
310
|
+
**Who answers.** Members only, no explorers: an agent's working session is
|
|
311
|
+
not the place to try an unknown model. Excluded, and named in
|
|
312
|
+
`bench.skipped` with why, as a panel excludes them: retired and ad hoc
|
|
313
|
+
members, members whose endpoint speaks another API than chat completions,
|
|
314
|
+
members the catalog does not know to call tools (when the request has
|
|
315
|
+
tools; the model popup's *Tool calling* is the override, like *Vision /
|
|
316
|
+
OCR*; the catalog's `tools` comes from OpenRouter's listed parameters,
|
|
317
|
+
models.dev, the endpoint's own listing or a capability probe that saw a
|
|
318
|
+
call), that cannot see (when it has images) or whose context window is
|
|
319
|
+
under the request (tools and tool calls counted). No member left: 400
|
|
320
|
+
(413 when size is why). Of those left, the best record goes first: useful
|
|
321
|
+
verdicts counted, then verified suite findings, then a connection already
|
|
322
|
+
paid for; members cooling down after a rate limit go last. One new
|
|
323
|
+
conversation in four goes instead to the member whose record is oldest, so
|
|
324
|
+
the record does not narrow to one model.
|
|
325
|
+
|
|
326
|
+
**Sticky.** A conversation stays on its model across turns: switching
|
|
327
|
+
mid-task breaks the agent's coherence and the provider's prompt cache. The
|
|
328
|
+
key is a hash of the collection, the gateway key, the system text and the
|
|
329
|
+
first user message, which an agent resends unchanged every turn; it lives
|
|
330
|
+
in memory and lapses after an hour idle (or a restart). A side request with
|
|
331
|
+
its own system prompt (Claude Code's command checks, a subagent, a title) is
|
|
332
|
+
its own conversation. If the conversation's model cannot take a turn (an
|
|
333
|
+
image arrives), the turn is re-ranked; the model that answers becomes the
|
|
334
|
+
conversation's.
|
|
335
|
+
|
|
336
|
+
**Failover.** If the member fails before the first piece of its answer
|
|
337
|
+
reaches the caller — a 429, a 5xx, a pacing refusal, nothing at all in 90
|
|
338
|
+
seconds, an empty reply (even after reasoning) — the next member in order
|
|
339
|
+
gets the turn, transparently, up to three; the caller sees only the answer,
|
|
340
|
+
and `bench.failed_over` names who failed and why. Reasoning streamed before
|
|
341
|
+
the answer is held back with it. A streamed turn's 200 waits up to 15
|
|
342
|
+
seconds for that first piece, so a turn that fails everywhere quickly keeps
|
|
343
|
+
its status (429 when every member was rate limited, else 502), then
|
|
344
|
+
keep-alives cover the wait. After the first piece, an error goes into the
|
|
345
|
+
stream in the format's error shape: no other model can continue a
|
|
346
|
+
half-written answer.
|
|
347
|
+
|
|
348
|
+
**Streams.** OpenAI chat completions relays the member's chunks as they
|
|
349
|
+
came, tool-call deltas exactly, under the collection's name, then a closing
|
|
350
|
+
chunk with no choice that carries `bench`. The other formats stream the
|
|
351
|
+
text as it comes and each tool call **whole when the turn ends** (an
|
|
352
|
+
Anthropic `tool_use` block with one `input_json_delta`, a Responses
|
|
353
|
+
`function_call` item, a Gemini `functionCall` part, an Ollama `tool_calls`
|
|
354
|
+
line), so calls whose deltas interleave still arrive intact. Usage is the
|
|
355
|
+
member's own count. An output budget over the model's known maximum is cut
|
|
356
|
+
to it (Claude Code asks every model for 32,000). Headers name the
|
|
357
|
+
conversation (`X-Bench-Conversation`) and, when known before the 200, the
|
|
358
|
+
model (`X-Bench-Model`).
|
|
359
|
+
|
|
360
|
+
**Translations.** Anthropic `tool_use` / `tool_result` blocks, Responses
|
|
361
|
+
`function_call` / `function_call_output` items, Gemini `functionCall` /
|
|
362
|
+
`functionResponse` parts and Ollama `tool_calls` / `tool` messages become
|
|
363
|
+
OpenAI tool calls and tool messages and back; tool definitions and
|
|
364
|
+
`tool_choice` likewise (Gemini's upper-case schema types lowered). Tool call
|
|
365
|
+
ids pass through both ways; Gemini and Ollama usually send none, so they
|
|
366
|
+
are numbered per request and a result matched to the oldest open call of
|
|
367
|
+
its name. A Responses **custom tool** (Codex's free-form `apply_patch`)
|
|
368
|
+
becomes a function taking one string, `input`, its grammar in the
|
|
369
|
+
description, and the call goes back as a `custom_tool_call`. An image in an
|
|
370
|
+
Anthropic tool result goes in a user message after it (a tool message
|
|
371
|
+
carries text only); `is_error` prefixes the result with `Error: `.
|
|
372
|
+
|
|
373
|
+
**Recording (the decision).** Every call is in the usage ledger (purpose
|
|
374
|
+
`route`), and every turn a line in `runs/gateway/routes.jsonl`: model,
|
|
375
|
+
status, seconds, tool calls, tokens, who failed over — never the prompt,
|
|
376
|
+
which an agent resends whole every turn. For the router, a conversation is
|
|
377
|
+
**one unjudged offload per model that answered it** (call id
|
|
378
|
+
`<conversation>.1`, `.2` after a failover to another model), not one per
|
|
379
|
+
turn: an agent session is dozens of turns, and a seat per turn would bury
|
|
380
|
+
the panels' verdicts. A verdict on a conversation is optional
|
|
381
|
+
(`POST /bench/verdict` with that call id); the offload alone already counts
|
|
382
|
+
toward rotation. The Gateway card lists route conversations in their own
|
|
383
|
+
table (when, who asked, the first message, who answered with ✓/✗, turns,
|
|
384
|
+
tool calls, failovers) and none of them adds to **Owed**.
|
|
385
|
+
|
|
386
|
+
**Shadows (later, opt-in).** A shadow seat would answer the same turn in the
|
|
387
|
+
background for evaluation only, after the caller has its answer. The place
|
|
388
|
+
is marked in `route_turn` ("SHADOWS GO HERE"): each would be its own client
|
|
389
|
+
call with purpose `shadow`, recorded on the conversation's call id; nothing
|
|
390
|
+
the caller sees would wait for or change because of them. Not built.
|
|
391
|
+
|
|
392
|
+
**Limits, honestly.**
|
|
393
|
+
|
|
394
|
+
- Members reached through native Anthropic, Gemini or Responses endpoints
|
|
395
|
+
are not routed to: route mode speaks chat completions to the member.
|
|
396
|
+
- The member is a free or flat-rate model, not the model the client was
|
|
397
|
+
built for. Claude Code and Codex are tuned for their own vendors' models;
|
|
398
|
+
smaller models make more tool-call mistakes, and some providers reject a
|
|
399
|
+
tool schema another accepts (that request fails over).
|
|
400
|
+
- Prompt caching is the provider's own: stickiness keeps the prefix on one
|
|
401
|
+
provider, but `cache_control` marks are not passed on.
|
|
402
|
+
- Thinking is not shown to Anthropic, Responses, Gemini or Ollama clients
|
|
403
|
+
(a model's reasoning is relayed raw only in chat completions).
|
|
404
|
+
- Tool calls arrive whole, not as deltas, outside chat completions.
|
|
405
|
+
- A caller that hangs up does not stop the member; its answer runs out and
|
|
406
|
+
is recorded.
|
|
407
|
+
- Stickiness is per gateway process, in memory.
|
|
408
|
+
|
|
409
|
+
### Pointing agents at a collection
|
|
410
|
+
|
|
411
|
+
The base URL is the server: `http://127.0.0.1:8122` (the dashboard's port)
|
|
412
|
+
or `:8123` (`gateway.py`). On this machine any key text works; elsewhere,
|
|
413
|
+
or where keys are required, use a gateway key from Settings.
|
|
414
|
+
|
|
415
|
+
**Claude Code** (Anthropic Messages). It sends the model named by
|
|
416
|
+
`ANTHROPIC_MODEL` (or `--model`) for its main loop and a smaller "haiku"
|
|
417
|
+
model for side tasks (command checks, titles), so set both; a name it does
|
|
418
|
+
not know prints a harmless `unrecognized_model` note:
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8122
|
|
422
|
+
export ANTHROPIC_API_KEY=<a gateway key, or any text on this machine>
|
|
423
|
+
export ANTHROPIC_MODEL=bench/core:route
|
|
424
|
+
export ANTHROPIC_DEFAULT_HAIKU_MODEL=bench/fast:route
|
|
425
|
+
export ANTHROPIC_DEFAULT_SONNET_MODEL=bench/core:route ANTHROPIC_DEFAULT_OPUS_MODEL=bench/core:route
|
|
426
|
+
export CLAUDE_CODE_SUBAGENT_MODEL=bench/core:route
|
|
427
|
+
claude
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Checked end to end (2026-10-23): Claude Code 2.1.299 ran Read, Write and
|
|
431
|
+
Bash tool loops through `bench/core:route` and `bench/fast:route`, failing
|
|
432
|
+
over past a member's 429 on the first turn and staying on one model after.
|
|
433
|
+
|
|
434
|
+
**Codex and other Responses clients**: a provider with `base_url =
|
|
435
|
+
"http://127.0.0.1:8122/v1"`, `wire_api = "responses"`, the model
|
|
436
|
+
`bench/core:route`, and the gateway key as its API key. Function and custom
|
|
437
|
+
tools work; hosted tools (`web_search`, `local_shell`) are refused, so turn
|
|
438
|
+
them off. Nothing is stored: `previous_response_id` is refused, so the
|
|
439
|
+
client must send the whole conversation every turn.
|
|
440
|
+
Not checked against a real Codex yet.
|
|
441
|
+
|
|
442
|
+
**opencode, Cline, Continue, Roo, Aider and other OpenAI-compatible agents**:
|
|
443
|
+
an OpenAI-compatible provider at `http://127.0.0.1:8122/v1`, model
|
|
444
|
+
`bench/core:route` (it is in `/v1/models`). Agents that speak Ollama
|
|
445
|
+
(`http://127.0.0.1:8122`, model `bench/core:route`) work the same way. A
|
|
446
|
+
real `curl` with tools streamed a model's tool-call deltas through
|
|
447
|
+
unchanged; these clients themselves were not run.
|
|
448
|
+
|
|
449
|
+
**Counts and durations.** `usage` and the token-count routes
|
|
450
|
+
(`/v1/messages/count_tokens`, Gemini `:countTokens`) are the size check's
|
|
451
|
+
estimate, characters / 4 plus 10% and 2,500 a picture, with no model called.
|
|
452
|
+
Ollama's durations are real. `/api/version` reports the Ollama API version
|
|
453
|
+
whose shapes these follow (0.12.0), so clients send what it reads.
|
|
454
|
+
|
|
455
|
+
**On the dashboard's port**, Ollama's five `/api/` routes are the gateway's
|
|
456
|
+
(a gateway key, never the login cookie); the dashboard's own `/api/...`
|
|
457
|
+
routes never use those names.
|
|
458
|
+
|
|
160
459
|
## The decision model's job (optional)
|
|
161
460
|
|
|
162
461
|
A collection's members are fixed; only the explorer seats are chosen per
|
|
@@ -201,7 +500,8 @@ key limited to its collection and a rate, or keep the key on the app's server.
|
|
|
201
500
|
|
|
202
501
|
| Part | Job |
|
|
203
502
|
|---|---|
|
|
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
|
|
503
|
+
| 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. |
|
|
504
|
+
| 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. |
|
|
205
505
|
| Collections | `metrics/bench_collections.py` reads the two config files and computes explorer seats. |
|
|
206
506
|
| Seat router | `route.panel(members=…, explorers=n)` fills explorer seats by staleness for the shape. |
|
|
207
507
|
| Shape picker | Optional decision model (above). |
|
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ verdicts decide who sits on the next panel.
|
|
|
48
48
|
|
|
49
49
|
| Related tools | Examples | What they do | What Benchwarmers does differently |
|
|
50
50
|
|---|---|---|---|
|
|
51
|
-
| Gateways and routers | LiteLLM, OpenRouter, Portkey | One request to one model, with fallback | Fans one prompt out to a panel and returns every answer |
|
|
51
|
+
| Gateways and routers | LiteLLM, OpenRouter, Portkey | One request to one model, with fallback | Fans one prompt out to a panel and returns every answer; for agents, routes to one member by its record |
|
|
52
52
|
| Councils and ensembles | LLM Council, Mixture-of-Agents | Several models merged into one answer | Keeps every answer, because who said what is the evidence; one merged summary only if you ask |
|
|
53
53
|
| Side-by-side chat | Open WebUI, LMArena | Several answers shown together | Records a verdict per answer and uses them to pick the next panel |
|
|
54
54
|
| Evaluation harnesses | promptfoo, Inspect, SWE-bench | Score models offline on fixed test sets | Scores them on your real work and your own code, and keeps rotating new models in |
|
|
@@ -76,7 +76,8 @@ verdicts decide who sits on the next panel.
|
|
|
76
76
|
- [Licence](#licence)
|
|
77
77
|
|
|
78
78
|
Day-to-day use (the dashboard's pages, connecting apps, onboarding in bulk) is
|
|
79
|
-
in [USAGE.md](USAGE.md).
|
|
79
|
+
in [USAGE.md](USAGE.md). Running it on a server or a hosting platform is in
|
|
80
|
+
[HOSTING.md](HOSTING.md). A coding agent setting it up for someone should start
|
|
80
81
|
with [AGENTS.md](AGENTS.md).
|
|
81
82
|
|
|
82
83
|
## Try it
|
|
@@ -189,6 +190,27 @@ seat is still in `bench`. Record a verdict per call once you have checked it:
|
|
|
189
190
|
or from the dashboard. See [GATEWAY.md](GATEWAY.md) for the full contract:
|
|
190
191
|
images, prompt sizes, who may call, and the reply's shape.
|
|
191
192
|
|
|
193
|
+
**Other clients.** The same panel answers in other APIs' shapes, so a client
|
|
194
|
+
that speaks one of them works with the server's address as its base URL:
|
|
195
|
+
|
|
196
|
+
| Format | Route | For |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| OpenAI chat completions | `POST /v1/chat/completions` | OpenAI SDKs, most chat apps |
|
|
199
|
+
| OpenAI Responses | `POST /v1/responses` | `responses.create` clients (nothing is stored) |
|
|
200
|
+
| OpenAI legacy completions | `POST /v1/completions` | tools that send a `prompt` |
|
|
201
|
+
| Anthropic Messages | `POST /v1/messages` | Anthropic SDKs with `base_url` |
|
|
202
|
+
| Ollama | `POST /api/chat`, `/api/generate`, `GET /api/tags` | Open WebUI's Ollama connection, Ollama apps |
|
|
203
|
+
| Gemini | `POST /v1beta/models/bench/<collection>:generateContent` | Google GenAI SDKs with `base_url` |
|
|
204
|
+
|
|
205
|
+
curl http://127.0.0.1:8122/v1/messages -H "Content-Type: application/json" \
|
|
206
|
+
-d '{"model": "bench/<collection>", "max_tokens": 1024, "messages": [{"role": "user", "content": "..."}]}'
|
|
207
|
+
|
|
208
|
+
Each streams when asked: the reply starts at once, and each seat's answer
|
|
209
|
+
arrives as that seat finishes. Agent and tool use is not supported yet: a
|
|
210
|
+
request carrying tools is refused with a 400 rather than answered without
|
|
211
|
+
them. GATEWAY.md, "Other API formats", has a curl for each and what is
|
|
212
|
+
refused or ignored.
|
|
213
|
+
|
|
192
214
|
## From the command line
|
|
193
215
|
|
|
194
216
|
`bench` (or `benchwarmers`, the same program) does what the dashboard does,
|
|
@@ -272,6 +294,19 @@ On native Windows, a client that cannot start `npx` itself needs
|
|
|
272
294
|
| `BENCHWARMERS_KEY` | a gateway key from Settings, sent as `Authorization: Bearer`; needed for a server on another machine, or where keys are required |
|
|
273
295
|
| `BENCHWARMERS_TIMEOUT` | seconds to wait for a panel, default 900 |
|
|
274
296
|
|
|
297
|
+
**An agent running on a collection.** The MCP tools ask a panel for a
|
|
298
|
+
second opinion; an agent can also *be driven by* a collection, in **route
|
|
299
|
+
mode** (`bench/<collection>:route`): one member, picked by its record, gets
|
|
300
|
+
each turn with its tools, stays on for the whole conversation, and is
|
|
301
|
+
replaced by the next member if it fails before answering. Claude Code
|
|
302
|
+
points at it with `ANTHROPIC_BASE_URL=http://127.0.0.1:8122` and
|
|
303
|
+
`ANTHROPIC_MODEL=bench/core:route`; Codex, opencode, Cline and other
|
|
304
|
+
OpenAI-, Responses- or Ollama-speaking agents with the same base URL.
|
|
305
|
+
[GATEWAY.md](GATEWAY.md), "Route mode", has the set-up and the limits.
|
|
306
|
+
|
|
307
|
+
<!-- The MCP Registry's proof that the PyPI package is this server (server.json):
|
|
308
|
+
mcp-name: io.github.WendellResearch-AI/benchwarmers -->
|
|
309
|
+
|
|
275
310
|
## The suite
|
|
276
311
|
|
|
277
312
|
Models are scored on review work: find the faulty line, or say whether a
|
|
@@ -302,6 +337,18 @@ built-in client makes every call). Put the secrets in a `.env` file beside
|
|
|
302
337
|
then `docker compose up -d` and open http://127.0.0.1:8122. The compose file
|
|
303
338
|
publishes on this machine only; see its comments for a proxy.
|
|
304
339
|
|
|
340
|
+
**Hosted, always on:** [HOSTING.md](HOSTING.md) compares Fly.io, Railway,
|
|
341
|
+
Render and a VPS with Caddy (cost, sleep, disks, HTTPS), with a template for
|
|
342
|
+
each under [deploy/](deploy/).
|
|
343
|
+
|
|
344
|
+
[](https://render.com/deploy?repo=https://github.com/WendellResearch-AI/benchwarmers)
|
|
345
|
+
|
|
346
|
+
Render is one click: a paid instance with a 1 GB disk and a generated admin
|
|
347
|
+
token. Fly.io is four commands from a checkout (`cp deploy/fly.toml .`,
|
|
348
|
+
`fly launch --copy-config --no-deploy`, `fly secrets set
|
|
349
|
+
BENCHWARMERS_ADMIN_TOKEN=...`, `fly deploy`). Railway is a few steps in its CLI
|
|
350
|
+
or browser, in [HOSTING.md](HOSTING.md#railway).
|
|
351
|
+
|
|
305
352
|
## What leaves your machine
|
|
306
353
|
|
|
307
354
|
Benchwarmers has no account, no telemetry and no server of its own; nothing
|
|
@@ -325,7 +372,10 @@ detail.
|
|
|
325
372
|
|
|
326
373
|
## Limits
|
|
327
374
|
|
|
328
|
-
- **
|
|
375
|
+
- **No tools yet.** A panel answers with text, not a tool call, so agents
|
|
376
|
+
that need tool use (Claude Code, Codex, opencode as callers) wait for a
|
|
377
|
+
route mode; requests with tools are refused, not trimmed.
|
|
378
|
+
- **Early.** v0.1: no installer yet, merged replies stream in one piece, and the LiteLLM and
|
|
329
379
|
CLI Proxy API hub adapters are built from those projects' source, not yet
|
|
330
380
|
run against a real hub.
|
|
331
381
|
- **Calls cost.** Probes, onboarding and the suite run on your keys and free
|
package/USAGE.md
CHANGED
|
@@ -104,6 +104,37 @@ Images go in as OpenAI `image_url` parts; only models that take images sit,
|
|
|
104
104
|
and the rest are listed in `bench.skipped`. [GATEWAY.md](GATEWAY.md) has the
|
|
105
105
|
limits and the reply's full shape.
|
|
106
106
|
|
|
107
|
+
**A client that speaks another API** works too, with the server's address as
|
|
108
|
+
its base URL and a collection as its model:
|
|
109
|
+
|
|
110
|
+
| Client | Base URL | Notes |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| Open WebUI, as an **Ollama** connection | `http://127.0.0.1:8122` | collections listed from `/api/tags`; `bench/<collection>:merged` reads best in a chat window |
|
|
113
|
+
| **Anthropic** SDK (`anthropic.Anthropic(base_url=...)`) | `http://127.0.0.1:8122` | `/v1/messages`; a gateway key goes in `api_key` |
|
|
114
|
+
| **Google GenAI** SDK (`http_options=HttpOptions(base_url=...)`) | `http://127.0.0.1:8122` | `generate_content(model="bench/<collection>")`; a key in `api_key` |
|
|
115
|
+
| OpenAI **Responses** clients | `http://127.0.0.1:8122/v1` | `responses.create`; nothing is stored, so send the whole conversation |
|
|
116
|
+
| Older tools on `/v1/completions` | `http://127.0.0.1:8122/v1` | one `prompt` |
|
|
117
|
+
|
|
118
|
+
Asked to stream, each answers at once and sends each seat's answer as it
|
|
119
|
+
finishes. GATEWAY.md, "Other API formats", has a curl for each.
|
|
120
|
+
|
|
121
|
+
**An agent that calls tools** (Claude Code, Codex, opencode, Cline...) uses
|
|
122
|
+
a collection's **route mode**: model `bench/<collection>:route`. No panel:
|
|
123
|
+
one member, picked by its record, gets the request with its tools and
|
|
124
|
+
streams its answer back in the agent's own format; the conversation stays
|
|
125
|
+
on that model, and a member that fails before answering is replaced by the
|
|
126
|
+
next. A panel refuses tools with a 400 saying so. For Claude Code:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8122 ANTHROPIC_API_KEY=local
|
|
130
|
+
export ANTHROPIC_MODEL=bench/core:route ANTHROPIC_DEFAULT_HAIKU_MODEL=bench/fast:route
|
|
131
|
+
claude
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Each conversation is one offload per model that answered it; a verdict is
|
|
135
|
+
optional, and the Gateway card lists route conversations apart from the
|
|
136
|
+
panels. GATEWAY.md, "Route mode", has the other clients and the limits.
|
|
137
|
+
|
|
107
138
|
**A coding agent** connects over MCP instead: the Claude Code plugin
|
|
108
139
|
(`/plugin marketplace add WendellResearch-AI/benchwarmers`, then
|
|
109
140
|
`/plugin install benchwarmers@benchwarmers`), or `benchwarmers mcp` in any MCP
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.3.0
|
package/cli.py
CHANGED
|
@@ -258,7 +258,10 @@ def cmd_onboard(args) -> None:
|
|
|
258
258
|
reply = call("GET", "/api/onboard-queue") if verb == "status" else \
|
|
259
259
|
call("POST", f"/api/onboard-queue/{verb}", {})
|
|
260
260
|
else:
|
|
261
|
-
|
|
261
|
+
if args.ad_hoc and not args.quick:
|
|
262
|
+
sys.exit("--ad-hoc goes with --quick: an ad hoc model gets no suite run of its own")
|
|
263
|
+
mode = "quick-adhoc" if args.ad_hoc else "quick" if args.quick else "full"
|
|
264
|
+
items = [{"served": s, "endpoint": args.endpoint or "", "mode": mode} for s in args.served]
|
|
262
265
|
reply = call("POST", "/api/onboard-queue", {"items": items})
|
|
263
266
|
if args.wait:
|
|
264
267
|
while reply.get("running"):
|
|
@@ -482,6 +485,7 @@ keeps only the answers. Then judge each seat you checked:
|
|
|
482
485
|
bench candidates --new
|
|
483
486
|
bench onboard minimax/minimax-m3.1-flash:free --wait
|
|
484
487
|
bench onboard a/one:free b/two:free --endpoint openrouter
|
|
488
|
+
bench onboard command-a-plus-05-2026 --endpoint cohere --quick --ad-hoc # a trial key
|
|
485
489
|
bench onboard status | stop | clear""",
|
|
486
490
|
"collection": """examples:
|
|
487
491
|
bench collection save reviewers --members a,b,c --description "adversarial review" --tags review
|
|
@@ -590,6 +594,10 @@ def parser() -> argparse.ArgumentParser:
|
|
|
590
594
|
s.add_argument("served", nargs="+", help="served names, as `bench candidates` lists them")
|
|
591
595
|
s.add_argument("--endpoint", help="the endpoint they are on; the default if omitted")
|
|
592
596
|
s.add_argument("--wait", action="store_true", help="stay until the queue finishes")
|
|
597
|
+
s.add_argument("--quick", action="store_true",
|
|
598
|
+
help="probe only; the scheduled suite passes fill the suite in later")
|
|
599
|
+
s.add_argument("--ad-hoc", action="store_true",
|
|
600
|
+
help="with --quick: the bench never calls it on its own (metered, capped or trial keys)")
|
|
593
601
|
|
|
594
602
|
s = cmd("dismiss", cmd_dismiss, "stop offering served models as candidates")
|
|
595
603
|
s.add_argument("served", nargs="+"), s.add_argument("--endpoint"), s.add_argument("--undo", action="store_true")
|
|
@@ -607,7 +615,7 @@ def parser() -> argparse.ArgumentParser:
|
|
|
607
615
|
c.add_argument("--shape", help="the task shape explorers are picked for; empty for automatic")
|
|
608
616
|
c.add_argument("--description", help="what it is for: callers read it to choose")
|
|
609
617
|
c.add_argument("--tags", help="comma-separated")
|
|
610
|
-
c.add_argument("--reply", choices=["all", "merged"])
|
|
618
|
+
c.add_argument("--reply", choices=["all", "merged", "route"])
|
|
611
619
|
c.add_argument("--merger", help="the alias that merges, for --reply merged")
|
|
612
620
|
|
|
613
621
|
cmd("endpoints", cmd_endpoints, "where models are served from")
|