@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 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 and
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 first chunk):
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 it as one chunk, like
153
- any reply.
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. A streaming client gets the finished answer as one chunk. |
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). A coding agent setting it up for someone should start
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
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](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
- - **Early.** v0.1: no installer yet, no streaming replies, and the LiteLLM and
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.2.1
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
- items = [{"served": s, "endpoint": args.endpoint or ""} for s in args.served]
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")