@wendellresearch-ai/benchwarmers 0.3.0 → 0.3.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 +11 -8
- package/GATEWAY.md +9 -1
- package/PRIVACY.md +7 -5
- package/README.md +122 -51
- package/SECURITY.md +5 -4
- package/USAGE.md +19 -2
- package/VERSION +1 -1
- package/gateway.py +111 -2
- package/mcp_server.py +133 -36
- package/metrics/client.py +9 -8
- package/metrics/guard.py +8 -1
- package/package.json +1 -1
- package/scripts/test_mcp.py +207 -2
- package/scripts/test_serve_edit.py +2 -2
- package/scripts/test_verdict.py +18 -1
package/AGENTS.md
CHANGED
|
@@ -25,12 +25,11 @@ Python 3.11 or later; nothing else is required.
|
|
|
25
25
|
git clone https://github.com/WendellResearch-AI/benchwarmers
|
|
26
26
|
cd benchwarmers
|
|
27
27
|
python -m venv .venv
|
|
28
|
-
.venv/bin/pip install llm # optional; Windows: .venv\Scripts\pip install llm
|
|
29
28
|
|
|
30
|
-
The
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
`BENCH_CLIENT=
|
|
29
|
+
The built-in model client (`metrics/client.py`) makes every call and needs
|
|
30
|
+
nothing installed. The `llm` CLI is an optional alternative, for its plugins:
|
|
31
|
+
`pip install llm` in the venv, then choose it on Settings, Model client, or
|
|
32
|
+
with `BENCH_CLIENT=llm` (which wins over Settings).
|
|
34
33
|
|
|
35
34
|
Check: `python scripts/check.py --quick` ends with `all checks pass`.
|
|
36
35
|
|
|
@@ -67,8 +66,9 @@ API) or a local runtime (Ollama). Three ways, best first:
|
|
|
67
66
|
| `openrouter-2` | `BENCHWARMERS_KEY_OPENROUTER_2` |
|
|
68
67
|
| `my-hub` | `BENCHWARMERS_KEY_MY_HUB` |
|
|
69
68
|
|
|
70
|
-
Without the variable, keys come from
|
|
71
|
-
|
|
69
|
+
Without the variable, keys come from llm's key file (`keys.json` in llm's
|
|
70
|
+
user folder, which the dashboard and `init --key` write; the `llm` CLI need
|
|
71
|
+
not be installed). The variable wins when both exist.
|
|
72
72
|
|
|
73
73
|
One provider can be several endpoints (a pasted key and a sign-in, or two
|
|
74
74
|
accounts); each needs its own name, and Benchwarmers suggests a free one.
|
|
@@ -107,7 +107,10 @@ Any OpenAI-compatible client asks for the model `bench/<collection>`:
|
|
|
107
107
|
Every seat's answer comes back, plus a `bench` field with each seat's call id.
|
|
108
108
|
Clients of the Anthropic, Ollama, Gemini and Responses APIs work too, with
|
|
109
109
|
the server's address as their base URL (GATEWAY.md, "Other API formats");
|
|
110
|
-
|
|
110
|
+
a panel answers with text, so requests carrying tools are refused there.
|
|
111
|
+
An agent that needs tools (Claude Code, Codex, opencode) runs on a collection
|
|
112
|
+
in route mode instead, `bench/<collection>:route`: one member per turn, with
|
|
113
|
+
its tools, kept for the conversation (GATEWAY.md, "Route mode").
|
|
111
114
|
For a chat window that shows one answer, ask for `bench/<collection>:merged`
|
|
112
115
|
(or set the collection's reply to merged): a merger model writes one answer
|
|
113
116
|
saying where the seats agreed and disagreed, and every seat is still in
|
package/GATEWAY.md
CHANGED
|
@@ -167,6 +167,14 @@ A verdict is `POST /bench/verdict` with `{"call", "useful": true|false, "note",
|
|
|
167
167
|
dialog in the dashboard's Collections tab, under Gateway (click a seat). Sending the same verdict
|
|
168
168
|
twice writes nothing.
|
|
169
169
|
|
|
170
|
+
A client that weighs its seats by how they have done reads the verdicts back:
|
|
171
|
+
`GET /bench/record?collection=bench/vision&task=thicken slab faces` answers
|
|
172
|
+
`{"collection", "task", "aliases": {"<alias>": {"useful", "notUseful",
|
|
173
|
+
"judged", "lastAt"}}}`, counting each model's judged seats on that collection.
|
|
174
|
+
`task` (optional) keeps the verdicts whose note holds it. Counts only: never a
|
|
175
|
+
prompt, an answer or a note. thicken breaks ties between its vision seats with
|
|
176
|
+
it.
|
|
177
|
+
|
|
170
178
|
## Streaming
|
|
171
179
|
|
|
172
180
|
With `"stream": true` (or the format's own way of asking, below):
|
|
@@ -511,7 +519,7 @@ key limited to its collection and a rate, or keep the key on the app's server.
|
|
|
511
519
|
| 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. |
|
|
512
520
|
| 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"). |
|
|
513
521
|
| Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
|
|
514
|
-
| MCP server | `mcp_server.py
|
|
522
|
+
| MCP server | `mcp_server.py`: the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`. Two transports for one `answer()`: `POST /mcp` (or `/v1/mcp`) on either port, streamable HTTP with a JSON reply per request, its tools calling the server's own routes over loopback with the caller's credential and Host; and stdio (`benchwarmers mcp`), a caller like any other over HTTP (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`). The Claude Code plugin in `plugin/` connects to `/mcp` and adds a command, a skill and a `Stop` hook (`plugin/hooks/hooks.json`, an `mcp_tool` hook) that calls `pending_verdicts` with the session's `transcript_path`: the call ids in the transcript (Claude Code keeps no `Mcp-Session-Id`, so the transcript is the record of whose panels they were), of which the call log says which are answered seats with no verdict (`POST /bench/pending`), returned once as `additionalContext`. A server on another machine cannot read the transcript, so there the hook says nothing. A tool call answers as SSE with progress notifications, which reset Claude Code's 300-second idle limit. |
|
|
515
523
|
|
|
516
524
|
## Rules
|
|
517
525
|
|
package/PRIVACY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Privacy policy
|
|
2
2
|
|
|
3
|
-
*Effective
|
|
3
|
+
*Effective 23 October 2026.*
|
|
4
4
|
|
|
5
5
|
Benchwarmers runs on your own machine or server. It has no account, no
|
|
6
6
|
telemetry and no server of its own, and nothing you do with it reaches
|
|
@@ -22,13 +22,15 @@ Wendell Research.
|
|
|
22
22
|
- **Real-bug task seeding**, only when you run `suite/rebench.py`: dataset rows
|
|
23
23
|
from Hugging Face and source files from GitHub.
|
|
24
24
|
|
|
25
|
-
Keys and sign-in tokens stay in your environment or
|
|
26
|
-
|
|
25
|
+
Keys and sign-in tokens stay in your environment or llm's key file
|
|
26
|
+
(`keys.json` in llm's user folder, used whether or not the `llm` CLI is
|
|
27
|
+
installed). [SECURITY.md](SECURITY.md) has the full detail.
|
|
27
28
|
|
|
28
29
|
## What stays on your machine
|
|
29
30
|
|
|
30
|
-
Everything else: your endpoints, collections, verdicts, call logs,
|
|
31
|
-
|
|
31
|
+
Everything else: your endpoints, collections, verdicts, call logs, the usage
|
|
32
|
+
ledger (tokens per call, never the prompt), probe history and task answers
|
|
33
|
+
live in your data folder, which is yours to back up,
|
|
32
34
|
move or delete.
|
|
33
35
|
|
|
34
36
|
## Third parties
|
package/README.md
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
<a href="https://www.npmjs.com/package/@wendellresearch-ai/benchwarmers"><img src="https://img.shields.io/npm/v/@wendellresearch-ai/benchwarmers?color=cb3837&logo=npm" alt="npm"></a>
|
|
11
11
|
<a href="https://pypi.org/project/benchwarmers/"><img src="https://img.shields.io/pypi/v/benchwarmers?color=3775a9&logo=pypi&logoColor=white" alt="PyPI"></a>
|
|
12
12
|
<a href="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml"><img src="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml/badge.svg" alt="Checks"></a>
|
|
13
|
-
<a href="#docker"><img src="https://img.shields.io/badge/Docker-compose-2496ED?logo=docker&logoColor=white" alt="Runs in Docker"></a>
|
|
14
|
-
<
|
|
13
|
+
<a href="#docker-and-hosting"><img src="https://img.shields.io/badge/Docker-compose-2496ED?logo=docker&logoColor=white" alt="Runs in Docker"></a>
|
|
14
|
+
<br>
|
|
15
|
+
<a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-OpenAI_%7C_Responses-111111" alt="OpenAI chat completions and Responses API"></a>
|
|
16
|
+
<a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Anthropic_Messages-111111" alt="Anthropic Messages API"></a>
|
|
17
|
+
<a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Gemini-111111" alt="Gemini API"></a>
|
|
18
|
+
<a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Ollama-111111" alt="Ollama API"></a>
|
|
19
|
+
<a href="GATEWAY.md#route-mode-agent-clients"><img src="https://img.shields.io/badge/agents-tool_calling-111111" alt="Agent clients with tool calling, through route mode"></a>
|
|
20
|
+
<a href="https://registry.modelcontextprotocol.io/v0/servers?search=io.github.WendellResearch-AI/benchwarmers"><img src="https://img.shields.io/badge/MCP-Registry-111111" alt="MCP server in the official MCP Registry"></a>
|
|
15
21
|
<br>
|
|
16
22
|
<a href="#try-it"><img src="https://img.shields.io/badge/Python-3.11_%7C_3.12_%7C_3.13-3776AB?logo=python&logoColor=white" alt="Python 3.11 to 3.13"></a>
|
|
17
23
|
<a href="#try-it"><img src="https://img.shields.io/badge/core-stdlib_only-2ea44f" alt="The core needs only the Python standard library"></a>
|
|
@@ -30,8 +36,12 @@ verdicts decide who sits on the next panel.
|
|
|
30
36
|
|
|
31
37
|
**What it is best at:**
|
|
32
38
|
|
|
33
|
-
- **Asking several models at once.** One
|
|
34
|
-
answers. Where they disagree is where
|
|
39
|
+
- **Asking several models at once.** One request, in the OpenAI, Anthropic,
|
|
40
|
+
Gemini or Ollama format, a panel of answers. Where they disagree is where
|
|
41
|
+
to look.
|
|
42
|
+
- **Agents too.** Claude Code and other tool-calling clients use a collection
|
|
43
|
+
through route mode: one member per turn, kept for the conversation, with
|
|
44
|
+
failover, and every call on the record.
|
|
35
45
|
- **Reviewing.** Code review, claims about code, designs: different models
|
|
36
46
|
catch different things, and two models agreeing is not proof.
|
|
37
47
|
- **Playing to each model's strengths.** A collection per kind of work
|
|
@@ -43,8 +53,9 @@ verdicts decide who sits on the next panel.
|
|
|
43
53
|
repositories, plus your verdicts on real work, instead of public leaderboards.
|
|
44
54
|
- **Knowing who to bench.** Who is confidently wrong, who has stopped
|
|
45
55
|
answering (retired automatically), and who is resting after a rate limit.
|
|
46
|
-
- **Keeping free and cheap models useful.** Health probes,
|
|
47
|
-
and one endpoint for every app, over
|
|
56
|
+
- **Keeping free and cheap models useful.** Health probes, rate limits taken
|
|
57
|
+
from what providers say and enforced, and one endpoint for every app, over
|
|
58
|
+
any OpenAI-compatible provider or hub, or Anthropic's and Gemini's own APIs.
|
|
48
59
|
|
|
49
60
|
| Related tools | Examples | What they do | What Benchwarmers does differently |
|
|
50
61
|
|---|---|---|---|
|
|
@@ -68,7 +79,7 @@ verdicts decide who sits on the next panel.
|
|
|
68
79
|
- [From the command line](#from-the-command-line)
|
|
69
80
|
- [From Claude Code and other agents](#from-claude-code-and-other-agents)
|
|
70
81
|
- [The suite](#the-suite)
|
|
71
|
-
- [Docker](#docker)
|
|
82
|
+
- [Docker and hosting](#docker-and-hosting)
|
|
72
83
|
- [What leaves your machine](#what-leaves-your-machine)
|
|
73
84
|
- [Limits](#limits)
|
|
74
85
|
- [Security](#security)
|
|
@@ -82,7 +93,7 @@ with [AGENTS.md](AGENTS.md).
|
|
|
82
93
|
|
|
83
94
|
## Try it
|
|
84
95
|
|
|
85
|
-
**v0.
|
|
96
|
+
**v0.3, early.** It runs with Python 3.11 or later on Windows, macOS and
|
|
86
97
|
Linux, from PyPI, npm, a checkout or Docker. Expect rough edges, and read the
|
|
87
98
|
code where these pages are thin.
|
|
88
99
|
|
|
@@ -108,12 +119,12 @@ dependencies either way: the app is the Python standard library.
|
|
|
108
119
|
|
|
109
120
|
git clone https://github.com/WendellResearch-AI/benchwarmers
|
|
110
121
|
cd benchwarmers
|
|
111
|
-
python -m venv .venv
|
|
112
|
-
.venv/bin/pip install llm # Windows: .venv\Scripts\pip install llm
|
|
122
|
+
python -m venv .venv # nothing to pip install: the standard library is enough
|
|
113
123
|
|
|
114
|
-
The
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
The built-in model client (`metrics/client.py`) makes every call. The
|
|
125
|
+
[`llm`](https://llm.datasette.io/) CLI is an alternative for its plugins:
|
|
126
|
+
install it in the venv and choose it on Settings (or `BENCH_CLIENT=llm`,
|
|
127
|
+
which wins).
|
|
117
128
|
|
|
118
129
|
2. **Make a data folder.** Everything that is yours (endpoints, model
|
|
119
130
|
registrations, collections, verdicts, probe history, task answers) lives
|
|
@@ -125,14 +136,16 @@ dependencies either way: the app is the Python standard library.
|
|
|
125
136
|
unless you name one) and points the checkout at it with a
|
|
126
137
|
`.benchwarmers-home` file; `BENCHWARMERS_HOME` overrides that. With
|
|
127
138
|
`--endpoint NAME BASE_URL --key` it also adds a first endpoint and asks for
|
|
128
|
-
its key, which goes to the
|
|
129
|
-
the
|
|
139
|
+
its key, which goes to the key file in llm's user folder (`keys.json`,
|
|
140
|
+
shared with the `llm` CLI if you have it, not needed otherwise), never into
|
|
141
|
+
the data folder. `BENCHWARMERS_KEY_<NAME>` in the environment works too.
|
|
130
142
|
|
|
131
143
|
3. **Start the dashboard.** With no endpoint yet it opens on adding one: sign
|
|
132
144
|
in with a provider on its own page (OpenRouter, NanoGPT, Hugging Face,
|
|
133
145
|
Pollinations, your ChatGPT plan, GitHub Models, or a local Ollama), or paste
|
|
134
146
|
an API key for any OpenAI-compatible API, a hub such as Open WebUI or
|
|
135
|
-
LiteLLM included.
|
|
147
|
+
LiteLLM included. Anthropic and Gemini keys can also be called through
|
|
148
|
+
those providers' own APIs (the API choice in the same dialog). Only providers that publish a sign-in for other apps are
|
|
136
149
|
offered; adding one is an entry in `metrics/connect.py` (its docstring says
|
|
137
150
|
how).
|
|
138
151
|
|
|
@@ -140,7 +153,9 @@ dependencies either way: the app is the Python standard library.
|
|
|
140
153
|
|
|
141
154
|
4. **Onboard a few models** from it ("Onboard a model"): tick one or several;
|
|
142
155
|
each is registered, probed and run through the suite, one at a time, with
|
|
143
|
-
the progress beside the button.
|
|
156
|
+
the progress beside the button. **Quick** probes now and leaves the suite
|
|
157
|
+
to the scheduled passes; **Quick + ad hoc** also keeps the bench from ever
|
|
158
|
+
calling the model on its own, for a metered or trial key.
|
|
144
159
|
|
|
145
160
|
5. **Make a collection** on the Collections page (the models a panel always
|
|
146
161
|
includes, and what it is for). The dashboard answers the gateway's routes on
|
|
@@ -163,10 +178,16 @@ dependencies either way: the app is the Python standard library.
|
|
|
163
178
|
- **Health.** Every morning each model gets a one-word prompt; once a week, a
|
|
164
179
|
short and a long one for speed. A model that stops answering, or that its
|
|
165
180
|
endpoint no longer serves, is retired automatically after one confirming
|
|
166
|
-
call, with the evidence kept, and can be restored in one click.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
181
|
+
call, with the evidence kept, and can be restored in one click. The
|
|
182
|
+
built-in scheduler runs all of this once Settings switches it on; a fresh
|
|
183
|
+
install spends no calls until then.
|
|
184
|
+
- **Rate limits and usage.** Limits come from providers' rate-limit headers
|
|
185
|
+
and error messages, and from what you set per connection or model
|
|
186
|
+
(requests a minute, day or month; tokens a minute or day). They are kept per
|
|
187
|
+
connection, since models behind one key share them, and enforced: a call
|
|
188
|
+
waits when the wait fits, or is refused before it goes out. Every call lands
|
|
189
|
+
in a monthly usage ledger with its tokens, and the Quotas page shows what is
|
|
190
|
+
left and when it resets.
|
|
170
191
|
- **Discover.** models.dev and FreeLLMAPI, merged into one list of models
|
|
171
192
|
worth adding, each with the routes this install has to it.
|
|
172
193
|
|
|
@@ -206,10 +227,11 @@ that speaks one of them works with the server's address as its base URL:
|
|
|
206
227
|
-d '{"model": "bench/<collection>", "max_tokens": 1024, "messages": [{"role": "user", "content": "..."}]}'
|
|
207
228
|
|
|
208
229
|
Each streams when asked: the reply starts at once, and each seat's answer
|
|
209
|
-
arrives as that seat finishes.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
230
|
+
arrives as that seat finishes. A panel answers with text, so a request
|
|
231
|
+
carrying tools is refused with a 400 that points to **route mode**
|
|
232
|
+
(`bench/<collection>:route`, one member with its tools; see
|
|
233
|
+
[below](#from-claude-code-and-other-agents)). GATEWAY.md, "Other API formats",
|
|
234
|
+
has a curl for each and what is refused or ignored.
|
|
213
235
|
|
|
214
236
|
## From the command line
|
|
215
237
|
|
|
@@ -257,28 +279,47 @@ no server at `BENCHWARMERS_URL`.
|
|
|
257
279
|
|
|
258
280
|
## From Claude Code and other agents
|
|
259
281
|
|
|
260
|
-
|
|
261
|
-
`
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
282
|
+
A running Benchwarmers is an MCP server at `/mcp` on its own port
|
|
283
|
+
(`http://127.0.0.1:8122/mcp`; `/v1/mcp` works too), over streamable HTTP,
|
|
284
|
+
with three tools: `list_collections`, `ask` (a collection, a prompt,
|
|
285
|
+
optionally `merged`, `with` and `system`) and `verdict`. Nothing to start per
|
|
286
|
+
client: every agent session shares the one server, behind the same Host,
|
|
287
|
+
Origin and gateway-key checks as `/v1`. The tool descriptions carry the
|
|
288
|
+
rules: ask a collection, not a favourite model; verify before acting; name
|
|
289
|
+
every seat; send a verdict per seat you judged.
|
|
267
290
|
|
|
268
|
-
**Claude Code plugin.** The plugin
|
|
269
|
-
<collection> <what to ask>` command and a `benchwarmers`
|
|
270
|
-
panel helps (adversarial review, exhaustive enumeration
|
|
271
|
-
how to judge it:
|
|
291
|
+
**Claude Code plugin.** The plugin connects to that `/mcp` and adds a
|
|
292
|
+
`/benchwarmers:ask <collection> <what to ask>` command and a `benchwarmers`
|
|
293
|
+
skill saying when a panel helps (adversarial review, exhaustive enumeration
|
|
294
|
+
over real files) and how to judge it:
|
|
272
295
|
|
|
273
296
|
/plugin marketplace add WendellResearch-AI/benchwarmers
|
|
274
297
|
/plugin install benchwarmers@benchwarmers
|
|
275
298
|
|
|
276
|
-
It
|
|
299
|
+
It finds the server through `BENCHWARMERS_URL` (default
|
|
300
|
+
`http://127.0.0.1:8122`; set it if you changed `BENCH_PORT`) and sends
|
|
301
|
+
`BENCHWARMERS_KEY` where keys are required. It also adds a `Stop` hook: at
|
|
302
|
+
the end of a turn it asks the server (the `pending_verdicts` tool, so nothing
|
|
303
|
+
extra runs on your machine) which seats of that session's panels have no
|
|
304
|
+
verdict yet, and reminds Claude of them once, as hook feedback. The server
|
|
305
|
+
finds the session's panels in its transcript, so the reminder needs a server
|
|
306
|
+
on the same machine; with one elsewhere the hook stays silent. A verdict sent
|
|
307
|
+
any way counts, the dashboard's included. With the plugin enabled and no
|
|
308
|
+
Benchwarmers running, the hook shows a hook-error notice at the end of each
|
|
309
|
+
turn, as `/mcp` shows the server as failed; start the server, or disable the
|
|
310
|
+
plugin for that session.
|
|
311
|
+
|
|
312
|
+
**Any other MCP client** that speaks HTTP:
|
|
313
|
+
|
|
314
|
+
```json
|
|
315
|
+
{"mcpServers": {"benchwarmers": {"type": "http", "url": "http://127.0.0.1:8122/mcp"}}}
|
|
316
|
+
```
|
|
277
317
|
|
|
278
|
-
|
|
279
|
-
|
|
318
|
+
with `"headers": {"Authorization": "Bearer <gateway key>"}` where keys are
|
|
319
|
+
required. **A client that only speaks stdio**: run `benchwarmers mcp` once
|
|
320
|
+
installed, `uvx benchwarmers mcp` or
|
|
280
321
|
`npx -y @wendellresearch-ai/benchwarmers mcp` without installing, or `python mcp_server.py` from a
|
|
281
|
-
checkout. For example:
|
|
322
|
+
checkout, a small process per client that calls the same server. For example:
|
|
282
323
|
|
|
283
324
|
```json
|
|
284
325
|
{"mcpServers": {"benchwarmers": {"command": "npx", "args": ["-y", "@wendellresearch-ai/benchwarmers", "mcp"],
|
|
@@ -294,6 +335,24 @@ On native Windows, a client that cannot start `npx` itself needs
|
|
|
294
335
|
| `BENCHWARMERS_KEY` | a gateway key from Settings, sent as `Authorization: Bearer`; needed for a server on another machine, or where keys are required |
|
|
295
336
|
| `BENCHWARMERS_TIMEOUT` | seconds to wait for a panel, default 900 |
|
|
296
337
|
|
|
338
|
+
**Behind an MCP aggregator** (1mcp, MetaMCP, mcp-proxy, Docker's MCP Gateway
|
|
339
|
+
and the like): add `http://127.0.0.1:8122/mcp` as an HTTP (streamable HTTP)
|
|
340
|
+
upstream, with the `Authorization` header where keys are required; an
|
|
341
|
+
aggregator that only runs stdio servers takes `benchwarmers mcp` as above.
|
|
342
|
+
Give it a request timeout of a few minutes or more, as a panel takes that
|
|
343
|
+
long; 1mcp's default of 60 seconds ends most of them. With 1mcp, for example:
|
|
344
|
+
|
|
345
|
+
1mcp mcp add benchwarmers --type http --url http://127.0.0.1:8122/mcp --timeout 900000
|
|
346
|
+
|
|
347
|
+
The aggregator prefixes the tool names (1mcp's are
|
|
348
|
+
`benchwarmers_1mcp_ask` and so on). If you also use the Claude Code plugin,
|
|
349
|
+
disable the plugin's own `benchwarmers` server in `/mcp` so the tools are not
|
|
350
|
+
listed twice; the skill and `/benchwarmers:ask` keep working, though the
|
|
351
|
+
command then asks before each tool call, since its pre-approved tools carry
|
|
352
|
+
the plugin's names. The plugin's verdict hook calls the plugin's own server,
|
|
353
|
+
so it does not work through an aggregator: with that server disabled it only
|
|
354
|
+
shows a hook-error notice at the end of each turn.
|
|
355
|
+
|
|
297
356
|
**An agent running on a collection.** The MCP tools ask a panel for a
|
|
298
357
|
second opinion; an agent can also *be driven by* a collection, in **route
|
|
299
358
|
mode** (`bench/<collection>:route`): one member, picked by its record, gets
|
|
@@ -324,7 +383,7 @@ code makes the best tasks, because no model has trained on it. See
|
|
|
324
383
|
[suite/README.md](suite/README.md), [suite/REBENCH.md](suite/REBENCH.md) and
|
|
325
384
|
[BENCHMARKS.md](BENCHMARKS.md).
|
|
326
385
|
|
|
327
|
-
## Docker
|
|
386
|
+
## Docker and hosting
|
|
328
387
|
|
|
329
388
|
One container runs the dashboard, the gateway (on the same port, at `/v1`)
|
|
330
389
|
and the scheduler, with the data folder as a volume and no `llm` CLI (the
|
|
@@ -366,21 +425,33 @@ reaches Wendell Research. What it sends, and where:
|
|
|
366
425
|
- **Seeding real-bug tasks**, only when you run `suite/rebench.py`: the
|
|
367
426
|
SWE-rebench-V2 rows from Hugging Face and the files from GitHub.
|
|
368
427
|
|
|
369
|
-
Keys stay in the environment or
|
|
370
|
-
folder. [PRIVACY.md](PRIVACY.md) and [SECURITY.md](SECURITY.md) have the
|
|
428
|
+
Keys stay in the environment or llm's key file (`keys.json` in its user
|
|
429
|
+
folder), never in the data folder. [PRIVACY.md](PRIVACY.md) and [SECURITY.md](SECURITY.md) have the
|
|
371
430
|
detail.
|
|
372
431
|
|
|
373
432
|
## Limits
|
|
374
433
|
|
|
375
|
-
- **
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
434
|
+
- **Tools go through route mode only.** A panel answers with text; a request
|
|
435
|
+
with tools is refused there, not trimmed. Route mode passes turns only to
|
|
436
|
+
members on chat-completions endpoints, keeps its sticky conversations in
|
|
437
|
+
memory (a restart starts each on a fresh pick), and has been run end to end
|
|
438
|
+
with Claude Code; the Codex, opencode and Cline set-ups are written from
|
|
439
|
+
their docs. Shadow seats (other members answering an agent's turn for
|
|
440
|
+
evaluation) are planned, not built.
|
|
441
|
+
- **Some parts are checked against fakes only.** The native Anthropic and
|
|
442
|
+
Gemini endpoints, and the LiteLLM and CLI Proxy API hub adapters, are built
|
|
443
|
+
from those APIs' documentation and source and tested against fake servers,
|
|
444
|
+
not yet against the real services.
|
|
445
|
+
- **A merged reply arrives in one piece** once the merger has finished; the
|
|
446
|
+
seats before it stream as they finish.
|
|
447
|
+
- **Pacing and the ledger cover the built-in client.** Calls made through the
|
|
448
|
+
`llm` CLI, when you choose it, are neither paced nor counted.
|
|
381
449
|
- **Calls cost.** Probes, onboarding and the suite run on your keys and free
|
|
382
|
-
tiers. Onboarding runs one model at a time for that reason
|
|
383
|
-
paced, not burst.
|
|
450
|
+
tiers. Onboarding runs one model at a time for that reason (Quick spreads
|
|
451
|
+
the suite over the scheduled passes); a suite pass is paced, not burst.
|
|
452
|
+
- **The scheduler lives in the server.** Its jobs run while the dashboard (or
|
|
453
|
+
the container, or the service) is up; a missed run is made up once, within
|
|
454
|
+
seven days, when it comes back.
|
|
384
455
|
- **One install, one person** at heart. Logins and gateway keys exist for a
|
|
385
456
|
LAN or a server, but there are no per-user views or roles.
|
|
386
457
|
- **The onboarding queue lives in the dashboard's memory.** Restarting it
|
package/SECURITY.md
CHANGED
|
@@ -58,7 +58,8 @@ open itself to anyone.
|
|
|
58
58
|
## What Benchwarmers touches
|
|
59
59
|
|
|
60
60
|
- **Keys.** Endpoint keys come from `BENCHWARMERS_KEY_<NAME>` (the key name
|
|
61
|
-
upper-cased, `-` as `_`) first, then from
|
|
61
|
+
upper-cased, `-` as `_`) first, then from llm's key file (`keys.json` in
|
|
62
|
+
its user folder, used whether or not the `llm` CLI is installed). They are
|
|
62
63
|
never written to the data folder, never shown back, and never put in a log.
|
|
63
64
|
Sign-ins through a provider's page store their token the same way.
|
|
64
65
|
- **The network.** Your prompts go only to the endpoints you add; the probes,
|
|
@@ -68,9 +69,9 @@ open itself to anyone.
|
|
|
68
69
|
dashboard's page, and Hugging Face and GitHub only when you run
|
|
69
70
|
`suite/rebench.py`. [PRIVACY.md](PRIVACY.md) lists them.
|
|
70
71
|
- **Files.** It writes inside its data folder (`BENCHWARMERS_HOME`, or the
|
|
71
|
-
folder `.benchwarmers-home` names) and, in
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
folder `.benchwarmers-home` names) and, in llm's user folder, the key file
|
|
73
|
+
(when you paste a key or sign in) and the model list (when it registers a
|
|
74
|
+
model). Images sent to the gateway are never written to a log.
|
|
74
75
|
- **Processes.** Onboarding, the probes and the suite run this repository's
|
|
75
76
|
own scripts with the same Python. Model ids that reach a command line are
|
|
76
77
|
checked against a strict pattern first.
|
package/USAGE.md
CHANGED
|
@@ -47,8 +47,19 @@ Dismiss hides one you never want offered (an image model, say); the dismissed
|
|
|
47
47
|
list brings it back.
|
|
48
48
|
|
|
49
49
|
Each model is registered, probed with four short calls, and, if it answers,
|
|
50
|
-
run through the suite.
|
|
51
|
-
|
|
50
|
+
run through the suite. **Onboard as**, under the list, chooses how much that
|
|
51
|
+
spends:
|
|
52
|
+
|
|
53
|
+
- **Full**: the probe, then the suite now.
|
|
54
|
+
- **Quick**: the probe now; the scheduled suite passes fill the suite in, six
|
|
55
|
+
tasks a run. The same calls, spread out.
|
|
56
|
+
- **Quick + ad hoc**: the probe only, and the bench never calls the model on
|
|
57
|
+
its own (no explorer seat, no scheduled runs). The one that saves calls, for
|
|
58
|
+
a metered, capped or trial key. A collection that lists it still calls it.
|
|
59
|
+
|
|
60
|
+
`bench onboard <served> --quick [--ad-hoc]` does the same from a terminal.
|
|
61
|
+
Onboarding takes minutes and spends calls, so models run **one at a time**,
|
|
62
|
+
in a queue the dashboard keeps:
|
|
52
63
|
|
|
53
64
|
- One model with nothing ahead of it is followed step by step in the popup.
|
|
54
65
|
- Otherwise the popup shows the queue, and a chip beside the button shows the
|
|
@@ -62,6 +73,12 @@ at a time**, in a queue the dashboard keeps:
|
|
|
62
73
|
A model that registered but did not answer stays on the bench as
|
|
63
74
|
unavailable, so the daily probe keeps watching it.
|
|
64
75
|
|
|
76
|
+
**Roles.** A model's popup sets what it is for: `general` (the default),
|
|
77
|
+
`code`, `light` (small and fast), `vision` (reads images well, weak at text:
|
|
78
|
+
OCR and document models; seated only when a request carries images) or
|
|
79
|
+
`image` (makes images; never seated). A model that reads images and does
|
|
80
|
+
everything else well stays `general`.
|
|
81
|
+
|
|
65
82
|
## Collections
|
|
66
83
|
|
|
67
84
|
A collection is a named panel: the models it always includes, the shape of
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.2
|
package/gateway.py
CHANGED
|
@@ -150,6 +150,7 @@ import client # noqa: E402
|
|
|
150
150
|
import decide # noqa: E402
|
|
151
151
|
import formats # noqa: E402
|
|
152
152
|
import guard # noqa: E402
|
|
153
|
+
import mcp_server # noqa: E402 -- its tools, answered at /mcp too
|
|
153
154
|
import model_roles # noqa: E402
|
|
154
155
|
import offload # noqa: E402
|
|
155
156
|
import route # noqa: E402
|
|
@@ -440,12 +441,33 @@ def too_small(needed: int, aliases) -> dict[str, int]:
|
|
|
440
441
|
|
|
441
442
|
def record_seat(task: str, call: str, label: str, alias: str, r: dict) -> dict:
|
|
442
443
|
"""Log one answer as an unjudged offload, the moment it is in, so explorer
|
|
443
|
-
rotation counts it even if nobody judges; returns the seat as logged.
|
|
444
|
+
rotation counts it even if nobody judges; returns the seat as logged. The
|
|
445
|
+
note's "<seat>; <status> in" is what /bench/pending reads the status from."""
|
|
444
446
|
offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
|
|
445
447
|
+ (f": {r['error'][:120]}" if r["error"] else ""), call)
|
|
446
448
|
return {"call": call, "seat": label, "alias": alias, **r}
|
|
447
449
|
|
|
448
450
|
|
|
451
|
+
def record(collection: str, task: str = "") -> dict[str, dict]:
|
|
452
|
+
"""Each model's verdicts on one collection's seats, for a client that weighs
|
|
453
|
+
its seats by how they have done (thicken breaks ties with it): useful, not
|
|
454
|
+
useful and judged, counted, and the last one's date. `task` keeps only the
|
|
455
|
+
verdicts whose note holds it ("thicken slab faces"). Counts only: never a
|
|
456
|
+
prompt, an answer or a note."""
|
|
457
|
+
out: dict[str, dict] = {}
|
|
458
|
+
for e in offload.entries():
|
|
459
|
+
# The collection ends each seat's task as run_collection writes it: "(bench/<name>, <shape>)".
|
|
460
|
+
if e.get("useful") is None or f"({collection}," not in e.get("task", ""):
|
|
461
|
+
continue
|
|
462
|
+
if task and task not in (e.get("note") or ""):
|
|
463
|
+
continue
|
|
464
|
+
r = out.setdefault(e["model"], {"useful": 0, "notUseful": 0, "judged": 0, "lastAt": ""})
|
|
465
|
+
r["useful" if e["useful"] else "notUseful"] += 1
|
|
466
|
+
r["judged"] += 1
|
|
467
|
+
r["lastAt"] = max(r["lastAt"], e.get("date", ""))
|
|
468
|
+
return out
|
|
469
|
+
|
|
470
|
+
|
|
449
471
|
def merger_candidates(collection: dict, members: list[str], shape: str, large: bool):
|
|
450
472
|
"""Who may merge, best first. The collection's `merger` alone when set;
|
|
451
473
|
else the members with verdicts on this shape, most useful first, then the
|
|
@@ -1030,6 +1052,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1030
1052
|
# orchestrator can choose one by task. Clients ignore extra keys.
|
|
1031
1053
|
return self._json(200, {"object": "list", "data": [
|
|
1032
1054
|
{"id": n, "object": "model", "owned_by": "benchwarmers", **d} for n, d in listed(self.who)]})
|
|
1055
|
+
if path == "/bench/record":
|
|
1056
|
+
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
|
1057
|
+
collection, task = (query.get(k, [""])[0] for k in ("collection", "task"))
|
|
1058
|
+
if not collection:
|
|
1059
|
+
return self._error(400, "GET /bench/record?collection=bench/<name>[&task=<words in the verdicts' notes>]")
|
|
1060
|
+
return self._json(200, {"collection": collection, "task": task, "aliases": record(collection, task)})
|
|
1033
1061
|
if path == "/api/tags":
|
|
1034
1062
|
return self._json(200, formats.ollama_tags(listed(self.who)))
|
|
1035
1063
|
if path == "/api/version":
|
|
@@ -1041,7 +1069,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1041
1069
|
row = dict(listed(self.who)).get(name)
|
|
1042
1070
|
return self._json(200, formats.gemini_model(name, row)) if row else \
|
|
1043
1071
|
self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
|
|
1044
|
-
|
|
1072
|
+
if guard.mcp_route(path): # no stream from the server: every reply is a POST's
|
|
1073
|
+
return self._json(405, {"error": {"message": "POST /mcp, one JSON-RPC message per request"}},
|
|
1074
|
+
{"Allow": "POST"})
|
|
1075
|
+
self._error(404, "GET /v1/models, /bench/record, /api/tags, /api/version or /v1beta/models", fmt=fmt)
|
|
1045
1076
|
|
|
1046
1077
|
def do_POST(self): # noqa: N802
|
|
1047
1078
|
# Without this any web page could spend model calls, seat ad hoc
|
|
@@ -1053,6 +1084,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1053
1084
|
self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
|
|
1054
1085
|
except guard.Refused as r:
|
|
1055
1086
|
return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers, fmt)
|
|
1087
|
+
if guard.mcp_route(path):
|
|
1088
|
+
return self._mcp(length)
|
|
1056
1089
|
try:
|
|
1057
1090
|
body = json.loads(self.rfile.read(length) or b"{}")
|
|
1058
1091
|
except ValueError:
|
|
@@ -1071,6 +1104,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1071
1104
|
except KeyError as exc:
|
|
1072
1105
|
return self._error(404, exc.args[0])
|
|
1073
1106
|
return self._json(200, {"ok": True, "recorded": e})
|
|
1107
|
+
if path == "/bench/pending":
|
|
1108
|
+
# Which of these call ids are seats that answered (ok or empty, not errored) and have
|
|
1109
|
+
# no verdict yet, however one is sent (mcp_server.py's pending_verdicts, the plugin's
|
|
1110
|
+
# Stop hook). An id the log does not know is not a seat. A seat's first entry is
|
|
1111
|
+
# record_seat's, its note "<seat>; <status> in <s>s".
|
|
1112
|
+
calls = body.get("calls")
|
|
1113
|
+
if not (isinstance(calls, list) and all(isinstance(c, str) for c in calls)):
|
|
1114
|
+
return self._error(400, '"calls" must be a list of call ids')
|
|
1115
|
+
first, judged = {}, set()
|
|
1116
|
+
for e in offload.entries():
|
|
1117
|
+
first.setdefault(e.get("call"), e)
|
|
1118
|
+
if e.get("useful") is not None:
|
|
1119
|
+
judged.add(e.get("call"))
|
|
1120
|
+
return self._json(200, {"pending": [
|
|
1121
|
+
c for c in calls if c in first and c not in judged
|
|
1122
|
+
and "; error in " not in str(first[c].get("note", "")).split(":", 1)[0]]})
|
|
1074
1123
|
# Each format's request becomes one Ask (metrics/formats.py); a part
|
|
1075
1124
|
# that would be lost on the way, tools above all, is refused with why.
|
|
1076
1125
|
gemini = formats.gemini_route(path)
|
|
@@ -1099,6 +1148,66 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1099
1148
|
alt = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("alt", [""])[0]
|
|
1100
1149
|
self._panel(fmt, ask, sse=alt == "sse")
|
|
1101
1150
|
|
|
1151
|
+
def _mcp(self, length: int) -> None:
|
|
1152
|
+
"""MCP over streamable HTTP, a reply to each POST (JSON, or SSE for a
|
|
1153
|
+
tool call from a client that takes it): mcp_server.py's
|
|
1154
|
+
tools on this port, so a client starts no process of its own. They call
|
|
1155
|
+
this server's own routes over loopback with the caller's credential and
|
|
1156
|
+
Host (both already checked above), so a key's collections and rate
|
|
1157
|
+
limit hold exactly as on /v1."""
|
|
1158
|
+
try:
|
|
1159
|
+
msg = json.loads(self.rfile.read(length))
|
|
1160
|
+
except (ValueError, RecursionError): # RecursionError: nesting deeper than json can parse
|
|
1161
|
+
return self._json(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
1162
|
+
host, port = self.server.server_address[:2]
|
|
1163
|
+
host = {"0.0.0.0": "127.0.0.1", "::": "::1"}.get(host, host)
|
|
1164
|
+
sent = {h: self.headers[h] for h in ("Authorization", "Host", *guard.KEY_HEADERS) if self.headers.get(h)}
|
|
1165
|
+
conn = (f"http://{'[' + host + ']' if ':' in host else host}:{port}", sent)
|
|
1166
|
+
if isinstance(msg, dict) and msg.get("method") == "tools/call" and "id" in msg \
|
|
1167
|
+
and "text/event-stream" in (self.headers.get("Accept") or ""):
|
|
1168
|
+
return self._mcp_stream(msg, conn)
|
|
1169
|
+
reply = mcp_server.answer(msg, conn)
|
|
1170
|
+
if reply is None: # a notification: accepted, nothing to say
|
|
1171
|
+
self.send_response(202)
|
|
1172
|
+
self.send_header("Content-Length", "0")
|
|
1173
|
+
return self.end_headers()
|
|
1174
|
+
self._json(200, reply)
|
|
1175
|
+
|
|
1176
|
+
def _mcp_stream(self, msg: dict, conn: mcp_server.Conn) -> None:
|
|
1177
|
+
"""A tool call as an SSE stream: the 200 at once, then every KEEPALIVE
|
|
1178
|
+
seconds while a panel runs (minutes) a progress notification when the
|
|
1179
|
+
client gave a progressToken, else a keep-alive comment, then the reply.
|
|
1180
|
+
A comment keeps proxies' idle timeouts away; only progress resets a
|
|
1181
|
+
client's own (Claude Code aborts a tool silent for 300 seconds)."""
|
|
1182
|
+
done: queue.Queue = queue.Queue()
|
|
1183
|
+
threading.Thread(target=lambda: done.put(mcp_server.answer(msg, conn)), daemon=True).start()
|
|
1184
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1185
|
+
token = (params.get("_meta") or {}).get("progressToken") if isinstance(params.get("_meta"), dict) else None
|
|
1186
|
+
started = time.monotonic()
|
|
1187
|
+
try:
|
|
1188
|
+
self.send_response(200)
|
|
1189
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
1190
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1191
|
+
self.end_headers()
|
|
1192
|
+
while True:
|
|
1193
|
+
try:
|
|
1194
|
+
reply = done.get(timeout=KEEPALIVE)
|
|
1195
|
+
except queue.Empty:
|
|
1196
|
+
if token is None:
|
|
1197
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
1198
|
+
else:
|
|
1199
|
+
seconds = round(time.monotonic() - started)
|
|
1200
|
+
self.wfile.write(b"event: message\ndata: " + json.dumps(
|
|
1201
|
+
{"jsonrpc": "2.0", "method": "notifications/progress",
|
|
1202
|
+
"params": {"progressToken": token, "progress": seconds,
|
|
1203
|
+
"message": f"the panel is running, {seconds} s"}}).encode() + b"\n\n")
|
|
1204
|
+
self.wfile.flush()
|
|
1205
|
+
continue
|
|
1206
|
+
self.wfile.write(b"event: message\ndata: " + json.dumps(reply).encode() + b"\n\n")
|
|
1207
|
+
return
|
|
1208
|
+
except OSError:
|
|
1209
|
+
return # the caller left; the panel still finishes, every seat recorded
|
|
1210
|
+
|
|
1102
1211
|
def _show(self, body: dict) -> None:
|
|
1103
1212
|
"""POST /api/show: Ollama's model card, for a collection the key may call."""
|
|
1104
1213
|
name = formats.ollama_model(body.get("model") or body.get("name") or "")
|