@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.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/GATEWAY.md +1 -0
- package/README.md +39 -0
- package/USAGE.md +8 -0
- package/VERSION +1 -1
- package/bin/benchwarmers.js +5 -3
- package/gateway.py +56 -13
- package/mcp_server.py +266 -0
- package/metrics/client.py +22 -1
- package/metrics/route.py +6 -1
- package/package.json +2 -1
- package/scripts/check.py +2 -0
- package/scripts/test_llm_log.py +81 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +21 -0
- package/scripts/test_serve_edit.py +55 -3
- package/serve.py +127 -25
- package/suite/run.py +4 -2
- package/ui/bench.css +1 -0
- package/ui/bench.js +25 -4
package/GATEWAY.md
CHANGED
|
@@ -211,6 +211,7 @@ key limited to its collection and a rate, or keep the key on the app's server.
|
|
|
211
211
|
| Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily (scheduled task WRBench-Catalog); a free model whose price goes above zero raises a "went paid" alert. |
|
|
212
212
|
| Discover tab | `metrics/discover.py`: models.dev and FreeLLMAPI merged into one list of models worth adding, free first, marked by whether you can already reach the provider and whether the model is on the bench (on another provider counts as "also on bench"). |
|
|
213
213
|
| Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
|
|
214
|
+
| MCP server | `mcp_server.py` (`benchwarmers mcp`): the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`, over HTTP to a running server (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`), so it is a caller like any other. The Claude Code plugin in `plugin/` wraps it with a command and a skill. |
|
|
214
215
|
|
|
215
216
|
## Rules
|
|
216
217
|
|
package/README.md
CHANGED
|
@@ -64,6 +64,7 @@ verdicts decide who sits on the next panel.
|
|
|
64
64
|
- [Try it](#try-it)
|
|
65
65
|
- [How it works](#how-it-works)
|
|
66
66
|
- [Asking a panel](#asking-a-panel)
|
|
67
|
+
- [From Claude Code and other agents](#from-claude-code-and-other-agents)
|
|
67
68
|
- [The suite](#the-suite)
|
|
68
69
|
- [Docker](#docker)
|
|
69
70
|
- [What leaves your machine](#what-leaves-your-machine)
|
|
@@ -179,6 +180,44 @@ seat is still in `bench`. Record a verdict per call once you have checked it:
|
|
|
179
180
|
or from the dashboard. See [GATEWAY.md](GATEWAY.md) for the full contract:
|
|
180
181
|
images, prompt sizes, who may call, and the reply's shape.
|
|
181
182
|
|
|
183
|
+
## From Claude Code and other agents
|
|
184
|
+
|
|
185
|
+
`benchwarmers mcp` is an MCP server on stdio with three tools:
|
|
186
|
+
`list_collections`, `ask` (a collection, a prompt, optionally `merged`,
|
|
187
|
+
`with` and `system`) and `verdict`. It is a client of a Benchwarmers that is
|
|
188
|
+
already running, over the routes above, so start the dashboard (or the
|
|
189
|
+
service) first. The tool descriptions carry the rules: ask a collection, not
|
|
190
|
+
a favourite model; verify before acting; name every seat; send a verdict per
|
|
191
|
+
seat you judged.
|
|
192
|
+
|
|
193
|
+
**Claude Code plugin.** The plugin adds the MCP server, a `/benchwarmers:ask
|
|
194
|
+
<collection> <what to ask>` command and a `benchwarmers` skill saying when a
|
|
195
|
+
panel helps (adversarial review, exhaustive enumeration over real files) and
|
|
196
|
+
how to judge it:
|
|
197
|
+
|
|
198
|
+
/plugin marketplace add WendellResearch-AI/benchwarmers
|
|
199
|
+
/plugin install benchwarmers@benchwarmers
|
|
200
|
+
|
|
201
|
+
It needs Node 18 or later and Python 3.11 or later on PATH.
|
|
202
|
+
|
|
203
|
+
**Any other MCP client** (or Claude Code without the plugin): run
|
|
204
|
+
`npx -y @wendellresearch-ai/benchwarmers mcp`, or `python mcp_server.py` from a
|
|
205
|
+
checkout. For example:
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{"mcpServers": {"benchwarmers": {"command": "npx", "args": ["-y", "@wendellresearch-ai/benchwarmers", "mcp"],
|
|
209
|
+
"env": {"BENCHWARMERS_URL": "http://127.0.0.1:8122"}}}}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
On native Windows, a client that cannot start `npx` itself needs
|
|
213
|
+
`"command": "cmd", "args": ["/c", "npx", "-y", "@wendellresearch-ai/benchwarmers", "mcp"]`.
|
|
214
|
+
|
|
215
|
+
| Variable | Meaning |
|
|
216
|
+
| --- | --- |
|
|
217
|
+
| `BENCHWARMERS_URL` | the server, default `http://127.0.0.1:8122` (a `/v1` on the end is fine) |
|
|
218
|
+
| `BENCHWARMERS_KEY` | a gateway key from Settings, sent as `Authorization: Bearer`; needed for a server on another machine, or where keys are required |
|
|
219
|
+
| `BENCHWARMERS_TIMEOUT` | seconds to wait for a panel, default 900 |
|
|
220
|
+
|
|
182
221
|
## The suite
|
|
183
222
|
|
|
184
223
|
Models are scored on review work: find the faulty line, or say whether a
|
package/USAGE.md
CHANGED
|
@@ -104,6 +104,14 @@ 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 coding agent** connects over MCP instead: the Claude Code plugin
|
|
108
|
+
(`/plugin marketplace add WendellResearch-AI/benchwarmers`, then
|
|
109
|
+
`/plugin install benchwarmers@benchwarmers`), or `benchwarmers mcp` in any MCP
|
|
110
|
+
client, with `BENCHWARMERS_URL` and, where keys are required, a gateway key in
|
|
111
|
+
`BENCHWARMERS_KEY`. Its `ask` tool returns every seat with its call id and its
|
|
112
|
+
`verdict` tool records the verdicts; README, "From Claude Code and other
|
|
113
|
+
agents", has the set-up.
|
|
114
|
+
|
|
107
115
|
## Recording verdicts
|
|
108
116
|
|
|
109
117
|
A panel's answers are worth something only once someone has checked them
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.0
|
package/bin/benchwarmers.js
CHANGED
|
@@ -28,6 +28,7 @@ function usage() {
|
|
|
28
28
|
' serve the dashboard, with the gateway at /v1: http://localhost:8122 (the default)',
|
|
29
29
|
' gateway the gateway alone: http://127.0.0.1:8123/v1',
|
|
30
30
|
' init [...] set up a data folder, optionally with a first endpoint (init --help)',
|
|
31
|
+
' mcp an MCP server on stdio for agents, a client of a running Benchwarmers (BENCHWARMERS_URL)',
|
|
31
32
|
' --version the version',
|
|
32
33
|
'',
|
|
33
34
|
'Your data lives in BENCHWARMERS_HOME, else ' + defaultHome() + '.',
|
|
@@ -59,7 +60,7 @@ function main(argv) {
|
|
|
59
60
|
console.log(usage());
|
|
60
61
|
return 0;
|
|
61
62
|
}
|
|
62
|
-
const scripts = { serve: 'serve.py', gateway: 'gateway.py', init: path.join('scripts', 'init.py') };
|
|
63
|
+
const scripts = { serve: 'serve.py', gateway: 'gateway.py', mcp: 'mcp_server.py', init: path.join('scripts', 'init.py') };
|
|
63
64
|
if (!scripts[command]) {
|
|
64
65
|
console.error('benchwarmers: no command "' + command + '"\n\n' + usage());
|
|
65
66
|
return 2;
|
|
@@ -79,8 +80,9 @@ function main(argv) {
|
|
|
79
80
|
|
|
80
81
|
// The first run makes the data folder from examples/ (init.py never writes
|
|
81
82
|
// over a file already there, and with BENCHWARMERS_HOME set it leaves the
|
|
82
|
-
// package folder alone).
|
|
83
|
-
|
|
83
|
+
// package folder alone). Not for mcp: it keeps no data, and its stdout
|
|
84
|
+
// carries protocol messages only.
|
|
85
|
+
if (command !== 'init' && command !== 'mcp' && !fs.existsSync(home)) {
|
|
84
86
|
console.log('First run: making your data folder at ' + home);
|
|
85
87
|
const r = spawnSync(python[0], py(scripts.init, [home]), { stdio: 'inherit', env, cwd: ROOT });
|
|
86
88
|
if (r.status !== 0) return r.status || 1;
|
package/gateway.py
CHANGED
|
@@ -34,6 +34,11 @@ takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
|
|
|
34
34
|
the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
|
|
35
35
|
model in an explorer chair, as `route.py --with` does.
|
|
36
36
|
|
|
37
|
+
A retired model (`config/retired.json`) is never called: retiring takes it out
|
|
38
|
+
of every collection, and one a collection still names — as a member, `with`
|
|
39
|
+
or its merger — is skipped and named in `bench.skipped`, its seat going to an
|
|
40
|
+
explorer and the merge to a merger chosen by verdicts.
|
|
41
|
+
|
|
37
42
|
THE LOOP
|
|
38
43
|
--------
|
|
39
44
|
Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
|
|
@@ -263,6 +268,24 @@ def pick_shape(prompt: str) -> tuple[str, str]:
|
|
|
263
268
|
return answer["choice"], f"{picker}, {conf:.2f}"
|
|
264
269
|
|
|
265
270
|
|
|
271
|
+
def retired() -> dict[str, dict]:
|
|
272
|
+
"""config/retired.json: alias -> its record (when, why). Retiring a model
|
|
273
|
+
takes it out of every collection (serve.remove_model); this is for a
|
|
274
|
+
collection that still names one -- a hand edit, or one saved before that
|
|
275
|
+
-- so the seat is skipped with a reason instead of failing on the call."""
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads((DATA / "config" / "retired.json").read_text(encoding="utf-8"))
|
|
278
|
+
except (OSError, ValueError):
|
|
279
|
+
return {}
|
|
280
|
+
return data if isinstance(data, dict) else {}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def retired_why(entry: dict) -> str:
|
|
284
|
+
when = str(entry.get("at", ""))[:10] if isinstance(entry, dict) else ""
|
|
285
|
+
return (f"retired from the bench{' on ' + when if when else ''}; take it out of the collection"
|
|
286
|
+
" (Restore in its model popup brings it back)")
|
|
287
|
+
|
|
288
|
+
|
|
266
289
|
def retiring() -> set[str]:
|
|
267
290
|
"""Aliases not to spend an explorer seat on: those on a retiring alert
|
|
268
291
|
(failing their probes; 2026-10-16 both explorers of a bench/core call were
|
|
@@ -324,8 +347,10 @@ def _ask_llm(cmd: list[str], prompt: str) -> tuple[str, str]:
|
|
|
324
347
|
encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
|
|
325
348
|
env={**os.environ, "PYTHONIOENCODING": "utf-8"})
|
|
326
349
|
answer = done.stdout.strip()
|
|
327
|
-
# A non-zero exit is an error even when stderr is silent
|
|
328
|
-
|
|
350
|
+
# A non-zero exit is an error even when stderr is silent, unless it is
|
|
351
|
+
# only llm failing to log an answer it printed (client.py).
|
|
352
|
+
failed = done.returncode and not client.llm_log_failed_only(done.stdout, done.stderr)
|
|
353
|
+
error = (done.stderr.strip() or f"llm exited {done.returncode}") if failed else ""
|
|
329
354
|
except subprocess.TimeoutExpired:
|
|
330
355
|
answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
|
|
331
356
|
except Exception as exc: # noqa: BLE001
|
|
@@ -428,14 +453,30 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
428
453
|
s = bench_collections.settings()
|
|
429
454
|
raw = collection.get("members") or []
|
|
430
455
|
# The editor dedupes and caps; a hand-edited file may not have.
|
|
431
|
-
members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
|
|
432
|
-
|
|
456
|
+
members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
|
|
457
|
+
# A RETIRED MODEL IS NOT CALLED: it is no longer registered, so the call
|
|
458
|
+
# could only fail ("Unknown model"). Skipped, and named, before the cap so
|
|
459
|
+
# it frees its seat for the next member or an explorer. Retiring takes it
|
|
460
|
+
# out of the collection, so this is a collection that still names one.
|
|
461
|
+
gone = retired()
|
|
462
|
+
skipped = [{"seat": "member", "alias": a, "why": retired_why(gone[a])} for a in members if a in gone]
|
|
463
|
+
members = [a for a in members if a not in gone][:s["max_calls"]]
|
|
433
464
|
# A member named in `with` is already seated; it must not use up the slot.
|
|
434
465
|
nominated = [a for a in dict.fromkeys(nominated or []) if a not in members]
|
|
466
|
+
skipped += [{"seat": "with", "alias": a, "why": retired_why(gone[a])} for a in nominated
|
|
467
|
+
if a in gone and a not in {k["alias"] for k in skipped}]
|
|
468
|
+
nominated = [a for a in nominated if a not in gone]
|
|
469
|
+
merged = (reply or collection.get("reply") or "all") == "merged"
|
|
470
|
+
if collection.get("merger") in gone:
|
|
471
|
+
if merged:
|
|
472
|
+
skipped.append({"seat": "merger", "alias": collection["merger"],
|
|
473
|
+
"why": retired_why(gone[collection["merger"]]) + "; the merger is chosen by verdicts"})
|
|
474
|
+
collection = {**collection, "merger": ""}
|
|
475
|
+
n_explorers = bench_collections.explorers_for(len(members), s)
|
|
435
476
|
# AN IMAGE GOES ONLY TO A MODEL THAT CAN SEE IT: one that cannot would
|
|
436
477
|
# answer anyway, about a picture it never got. Skipped, and named.
|
|
437
|
-
skipped
|
|
438
|
-
|
|
478
|
+
skipped += [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
|
|
479
|
+
for a in group if pictures and (why := cannot_see(a))]
|
|
439
480
|
unseen = {k["alias"] for k in skipped}
|
|
440
481
|
sitting = [a for a in members if a not in unseen]
|
|
441
482
|
nominated = [a for a in nominated if a not in unseen]
|
|
@@ -477,7 +518,8 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
477
518
|
+ ", and no model that takes images was free for an explorer seat. Add one to the"
|
|
478
519
|
" collection, or call one whose together.capabilities on GET /v1/models lists vision.")
|
|
479
520
|
if not seats:
|
|
480
|
-
raise LookupError(f"collection {name!r} has no members and no explorer could be seated"
|
|
521
|
+
raise LookupError(f"collection {name!r} has no members and no explorer could be seated"
|
|
522
|
+
+ "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
|
|
481
523
|
panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
|
|
482
524
|
model = bench_collections.PREFIX + name
|
|
483
525
|
with ThreadPoolExecutor(max_workers=len(seats)) as pool:
|
|
@@ -491,8 +533,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
491
533
|
out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
|
|
492
534
|
f" explorers · explorer shape {shape} ({how})\n"]
|
|
493
535
|
if skipped:
|
|
494
|
-
out.append("Not asked
|
|
495
|
-
+ "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
|
|
536
|
+
out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
|
|
496
537
|
for (key, label, alias), r in zip(seats, results):
|
|
497
538
|
call = f"{panel_id}.{key}"
|
|
498
539
|
record["seats"].append(record_seat(task, call, label, alias, r))
|
|
@@ -500,16 +541,18 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
500
541
|
"_empty reply — usually a token budget, not incapacity_")
|
|
501
542
|
out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
|
|
502
543
|
out.append("---\nRecord a verdict per seat once checked against the code:\n"
|
|
503
|
-
"`
|
|
544
|
+
"`POST /bench/verdict` on this same server (see `bench.verdict`), or"
|
|
545
|
+
" `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
|
|
504
546
|
content = "\n".join(out)
|
|
505
547
|
# Beyond the OpenAI fields, so a caller reads seats without parsing the
|
|
506
548
|
# markdown. Clients that do not know it ignore it.
|
|
507
549
|
bench = {"panel": panel_id, "collection": model, "shape": shape, "shape_by": how,
|
|
508
550
|
"seats": [{k: s[k] for k in SEAT_KEYS} for s in record["seats"]],
|
|
509
|
-
|
|
510
|
-
|
|
551
|
+
# A path, not a URL: the same server answered, whichever port or
|
|
552
|
+
# proxy the caller came through (the dashboard serves these routes too).
|
|
553
|
+
"verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
|
|
511
554
|
**({"skipped": skipped} if skipped else {})}
|
|
512
|
-
if
|
|
555
|
+
if merged:
|
|
513
556
|
try:
|
|
514
557
|
merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
|
|
515
558
|
except Exception as exc: # noqa: BLE001 -- never lose the panel to the merge
|
package/mcp_server.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Benchwarmers as an MCP server, so an agent (Claude Code, or any MCP client)
|
|
2
|
+
can ask a collection and send its verdicts.
|
|
3
|
+
|
|
4
|
+
benchwarmers mcp # from npm: npx -y @wendellresearch-ai/benchwarmers mcp
|
|
5
|
+
python mcp_server.py # from a checkout
|
|
6
|
+
|
|
7
|
+
MCP over stdio: JSON-RPC 2.0, one message per line, nothing else on stdout.
|
|
8
|
+
A THIN CLIENT of a Benchwarmers server that is already running, over the same
|
|
9
|
+
HTTP routes any other caller uses (GATEWAY.md), so the panel, the call log and
|
|
10
|
+
the verdicts are that server's, whichever machine it is on:
|
|
11
|
+
|
|
12
|
+
BENCHWARMERS_URL the server, default http://127.0.0.1:8122 (a /v1 on the end is fine)
|
|
13
|
+
BENCHWARMERS_KEY a gateway key, sent as `Authorization: Bearer`; needed beyond
|
|
14
|
+
this machine, or where BENCHWARMERS_REQUIRE_KEYS is set (metrics/guard.py)
|
|
15
|
+
BENCHWARMERS_TIMEOUT seconds to wait for a panel, default 900
|
|
16
|
+
|
|
17
|
+
Tools: list_collections (GET /v1/models), ask (POST /v1/chat/completions),
|
|
18
|
+
verdict (POST /bench/verdict). A refusal from the server, or no server at
|
|
19
|
+
all, comes back as a tool error carrying its message. Standard library only,
|
|
20
|
+
and nothing from metrics/: the data folder is the server's, not this one's.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
# Newest first: the client's version when it is one of these, else the newest.
|
|
34
|
+
PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
35
|
+
PREFIX = "bench/"
|
|
36
|
+
VERSION = (Path(__file__).resolve().parent / "VERSION").read_text(encoding="utf-8").strip()
|
|
37
|
+
|
|
38
|
+
RULES = (
|
|
39
|
+
"Benchwarmers asks a COLLECTION of models at once (every member plus explorer seats it rotates in)"
|
|
40
|
+
" and keeps every answer. Rules: (1) ask a collection, not a favourite model; list_collections says"
|
|
41
|
+
" what each is for. (2) Verify before acting on anything a seat says: seats fail together, so"
|
|
42
|
+
" agreement is not evidence. (3) Send a verdict per seat you judged, with the claims it got right"
|
|
43
|
+
" in `real` and the untrue ones in `false`. (4) Name every seat that answered, by model and seat,"
|
|
44
|
+
" including errors and empty replies. An empty reply from a reasoning model is usually its token"
|
|
45
|
+
" budget, not incapacity. Good uses: exhaustive enumeration over real files, adversarial review."
|
|
46
|
+
" Keep the design and the decisions yourself.")
|
|
47
|
+
|
|
48
|
+
TOOLS = [
|
|
49
|
+
{"name": "list_collections",
|
|
50
|
+
"title": "List collections",
|
|
51
|
+
"description": "The collections this Benchwarmers server offers: name, what each is for, tags, members,"
|
|
52
|
+
" explorer seats, reply mode, and what the members handle together (smallest context"
|
|
53
|
+
" window, shared capabilities, tightest rate limits). Pick by task before calling ask.",
|
|
54
|
+
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
55
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": False}},
|
|
56
|
+
{"name": "ask",
|
|
57
|
+
"title": "Ask a collection",
|
|
58
|
+
"description": "Put a prompt to every seat of one collection at once and get every answer back, each"
|
|
59
|
+
" under its seat and model, plus the seats as data (alias, seat, status, call id). Can"
|
|
60
|
+
" take minutes. Ask a collection, not a favourite model. Give the seats the real text"
|
|
61
|
+
" (code, diff, file contents) in the prompt: they cannot read your files. Verify every"
|
|
62
|
+
" claim against the source before acting on it, name every seat that answered in what"
|
|
63
|
+
" you tell the user, then send a verdict per seat you judged.",
|
|
64
|
+
"inputSchema": {"type": "object", "required": ["collection", "prompt"], "additionalProperties": False,
|
|
65
|
+
"properties": {
|
|
66
|
+
"collection": {"type": "string",
|
|
67
|
+
"description": "A collection name, `core` or `bench/core` (list_collections)."},
|
|
68
|
+
"prompt": {"type": "string", "description": "The question, with everything it needs."},
|
|
69
|
+
"system": {"type": "string", "description": "A system message for every seat."},
|
|
70
|
+
"merged": {"type": "boolean",
|
|
71
|
+
"description": "true: one reconciled answer, every seat still called and"
|
|
72
|
+
" listed; false: every answer. Omit for the collection's own."},
|
|
73
|
+
"with": {"type": "array", "items": {"type": "string"},
|
|
74
|
+
"description": "Model aliases to seat in explorer chairs, this request only."}}},
|
|
75
|
+
"annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True}},
|
|
76
|
+
{"name": "verdict",
|
|
77
|
+
"title": "Record a verdict",
|
|
78
|
+
"description": "Judge one seat's answer by its call id, once you have checked it against the source. One"
|
|
79
|
+
" per seat you judged. List the claims it got right in `real` and the claims that are"
|
|
80
|
+
" untrue in `false`, one sentence each: they feed routing and the 'confidently wrong'"
|
|
81
|
+
" record, so a note alone is not enough.",
|
|
82
|
+
"inputSchema": {"type": "object", "required": ["call", "useful"], "additionalProperties": False,
|
|
83
|
+
"properties": {
|
|
84
|
+
"call": {"type": "string", "description": "The seat's call id, e.g. p20261022-101500-ab12.member1."},
|
|
85
|
+
"useful": {"type": "boolean"},
|
|
86
|
+
"note": {"type": "string", "description": "One or two sentences on why."},
|
|
87
|
+
"real": {"type": "array", "items": {"type": "string"},
|
|
88
|
+
"description": "Claims it made that are true, one sentence each."},
|
|
89
|
+
"false": {"type": "array", "items": {"type": "string"},
|
|
90
|
+
"description": "Claims it made that are untrue, one sentence each."}}},
|
|
91
|
+
"annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True,
|
|
92
|
+
"openWorldHint": False}},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ToolError(Exception):
|
|
97
|
+
"""A tool call that failed: its message goes back as the tool's result, isError."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def base_url() -> str:
|
|
101
|
+
url = (os.environ.get("BENCHWARMERS_URL") or "").strip().rstrip("/") or "http://127.0.0.1:8122"
|
|
102
|
+
return url.removesuffix("/v1")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def http(method: str, path: str, body: dict | None = None) -> dict:
|
|
106
|
+
url = base_url() + path
|
|
107
|
+
headers = {"Accept": "application/json"}
|
|
108
|
+
if key := (os.environ.get("BENCHWARMERS_KEY") or "").strip():
|
|
109
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
110
|
+
data = None
|
|
111
|
+
if body is not None:
|
|
112
|
+
data = json.dumps(body).encode()
|
|
113
|
+
headers["Content-Type"] = "application/json"
|
|
114
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
115
|
+
timeout = float(os.environ.get("BENCHWARMERS_TIMEOUT") or 900)
|
|
116
|
+
try:
|
|
117
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
118
|
+
return json.load(r)
|
|
119
|
+
except urllib.error.HTTPError as e:
|
|
120
|
+
raw = e.read().decode("utf-8", "replace")
|
|
121
|
+
try:
|
|
122
|
+
message = json.loads(raw)["error"]["message"]
|
|
123
|
+
except (ValueError, KeyError, TypeError):
|
|
124
|
+
message = raw.strip()[:500] or e.reason
|
|
125
|
+
raise ToolError(f"Benchwarmers refused {method} {path} ({e.code}): {message}") from None
|
|
126
|
+
except (urllib.error.URLError, ConnectionError) as e:
|
|
127
|
+
reason = getattr(e, "reason", e)
|
|
128
|
+
if isinstance(reason, TimeoutError):
|
|
129
|
+
raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
|
|
130
|
+
" set BENCHWARMERS_TIMEOUT for longer panels") from None
|
|
131
|
+
raise ToolError(f"Benchwarmers is not running at {base_url()} ({reason}). Start it (`benchwarmers`,"
|
|
132
|
+
" or its service), or point BENCHWARMERS_URL at the server you use.") from None
|
|
133
|
+
except TimeoutError:
|
|
134
|
+
raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
|
|
135
|
+
" set BENCHWARMERS_TIMEOUT for longer panels") from None
|
|
136
|
+
except ValueError:
|
|
137
|
+
raise ToolError(f"{base_url()} answered {method} {path} with something that is not JSON;"
|
|
138
|
+
" is BENCHWARMERS_URL a Benchwarmers server?") from None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def list_collections(_args: dict) -> list[str]:
|
|
142
|
+
rows = []
|
|
143
|
+
for m in http("GET", "/v1/models").get("data") or []:
|
|
144
|
+
if ":" in m.get("id", ""): # the same collection in its other reply mode
|
|
145
|
+
continue
|
|
146
|
+
together = m.get("together") or {}
|
|
147
|
+
rows.append({"name": m["id"], **{k: m.get(k) for k in ("description", "tags", "members", "explorers",
|
|
148
|
+
"shape", "reply", "merger")},
|
|
149
|
+
**{k: together.get(k) for k in ("context_window", "capabilities", "limits")}})
|
|
150
|
+
if not rows:
|
|
151
|
+
return ["No collections on this server (or none this gateway key may call). Make one on the"
|
|
152
|
+
" dashboard's Collections page."]
|
|
153
|
+
return [json.dumps(rows, indent=1, ensure_ascii=False)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ask(args: dict) -> list[str]:
|
|
157
|
+
collection, prompt = args.get("collection"), args.get("prompt")
|
|
158
|
+
if not (isinstance(collection, str) and collection.strip()):
|
|
159
|
+
raise ToolError("`collection` is required: a name from list_collections, e.g. core")
|
|
160
|
+
if not (isinstance(prompt, str) and prompt.strip()):
|
|
161
|
+
raise ToolError("`prompt` is required")
|
|
162
|
+
collection = collection.strip()
|
|
163
|
+
messages = [{"role": "user", "content": prompt}]
|
|
164
|
+
if isinstance(args.get("system"), str) and args["system"].strip():
|
|
165
|
+
messages.insert(0, {"role": "system", "content": args["system"]})
|
|
166
|
+
body = {"model": collection if collection.startswith(PREFIX) else PREFIX + collection, "messages": messages}
|
|
167
|
+
if isinstance(args.get("merged"), bool):
|
|
168
|
+
body["bench"] = {"reply": "merged" if args["merged"] else "all"}
|
|
169
|
+
if args.get("with"):
|
|
170
|
+
body["with"] = args["with"]
|
|
171
|
+
reply = http("POST", "/v1/chat/completions", body)
|
|
172
|
+
content = reply["choices"][0]["message"]["content"]
|
|
173
|
+
bench = reply.get("bench") or {}
|
|
174
|
+
merged = bench.get("reply") == "merged"
|
|
175
|
+
# Each answer is already in the content unless it is the merge, so the
|
|
176
|
+
# seat data stays short: who sat, how it went, the id to judge it by.
|
|
177
|
+
keep = ("call", "seat", "alias", "status", "seconds", "error") + (("answer",) if merged else ())
|
|
178
|
+
seats = [{k: s[k] for k in keep if s.get(k) not in (None, "")} for s in bench.get("seats") or []]
|
|
179
|
+
for s in seats:
|
|
180
|
+
if "error" in s:
|
|
181
|
+
s["error"] = s["error"][-300:] # a traceback's last lines say what went wrong
|
|
182
|
+
data = {"panel": bench.get("panel"), "collection": bench.get("collection"), "shape": bench.get("shape"),
|
|
183
|
+
"seats": seats, **{k: bench[k] for k in ("merger", "skipped", "note") if bench.get(k)}}
|
|
184
|
+
return [content, "Seats (name each one; send a verdict per seat you judged, by call id):\n"
|
|
185
|
+
+ json.dumps(data, indent=1, ensure_ascii=False)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def verdict(args: dict) -> list[str]:
|
|
189
|
+
if not (isinstance(args.get("call"), str) and args["call"].strip()):
|
|
190
|
+
raise ToolError("`call` is required: the seat's call id from ask")
|
|
191
|
+
if not isinstance(args.get("useful"), bool):
|
|
192
|
+
raise ToolError("`useful` must be true or false")
|
|
193
|
+
body = {"call": args["call"].strip(), "useful": args["useful"], "note": args.get("note") or "",
|
|
194
|
+
"real": args.get("real") or [], "false": args.get("false") or []}
|
|
195
|
+
http("POST", "/bench/verdict", body)
|
|
196
|
+
return [f"Recorded {body['call']}: {'useful' if body['useful'] else 'not useful'},"
|
|
197
|
+
f" {len(body['real'])} real, {len(body['false'])} false."]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
HANDLERS = {"list_collections": list_collections, "ask": ask, "verdict": verdict}
|
|
201
|
+
OUT = threading.Lock()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def send(message: dict) -> None:
|
|
205
|
+
line = json.dumps(message, ensure_ascii=False) + "\n"
|
|
206
|
+
with OUT:
|
|
207
|
+
sys.stdout.buffer.write(line.encode("utf-8"))
|
|
208
|
+
sys.stdout.buffer.flush()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def call_tool(rid, params: dict) -> None:
|
|
212
|
+
name, args = params.get("name"), params.get("arguments") or {}
|
|
213
|
+
if name not in HANDLERS:
|
|
214
|
+
return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name!r}"}})
|
|
215
|
+
try:
|
|
216
|
+
if not isinstance(args, dict):
|
|
217
|
+
raise ToolError("arguments must be an object")
|
|
218
|
+
texts, failed = HANDLERS[name](args), False
|
|
219
|
+
except ToolError as e:
|
|
220
|
+
texts, failed = [str(e)], True
|
|
221
|
+
except Exception as e: # noqa: BLE001 -- a bad reply must not kill the server
|
|
222
|
+
texts, failed = [f"{type(e).__name__}: {e}"], True
|
|
223
|
+
send({"jsonrpc": "2.0", "id": rid,
|
|
224
|
+
"result": {"content": [{"type": "text", "text": t} for t in texts], "isError": failed}})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def handle(msg) -> None:
|
|
228
|
+
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
|
|
229
|
+
rid = msg.get("id") if isinstance(msg, dict) else None
|
|
230
|
+
return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}})
|
|
231
|
+
if "id" not in msg: # a notification (initialized, cancelled): nothing to answer
|
|
232
|
+
return
|
|
233
|
+
rid, method, params = msg["id"], msg["method"], msg.get("params") or {}
|
|
234
|
+
if method == "initialize":
|
|
235
|
+
asked = params.get("protocolVersion")
|
|
236
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {
|
|
237
|
+
"protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0],
|
|
238
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
239
|
+
"serverInfo": {"name": "benchwarmers", "title": "Benchwarmers", "version": VERSION},
|
|
240
|
+
"instructions": RULES}})
|
|
241
|
+
elif method == "ping":
|
|
242
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
|
243
|
+
elif method == "tools/list":
|
|
244
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}})
|
|
245
|
+
elif method == "tools/call":
|
|
246
|
+
# A panel takes minutes: on its own thread, so a ping or a second call is answered meanwhile.
|
|
247
|
+
# Not a daemon: when stdin closes, a call already sent (a verdict) still finishes.
|
|
248
|
+
threading.Thread(target=call_tool, args=(rid, params)).start()
|
|
249
|
+
else:
|
|
250
|
+
send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no method {method!r}"}})
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main() -> None:
|
|
254
|
+
for raw in sys.stdin.buffer:
|
|
255
|
+
if not raw.strip():
|
|
256
|
+
continue
|
|
257
|
+
try:
|
|
258
|
+
msg = json.loads(raw)
|
|
259
|
+
except ValueError:
|
|
260
|
+
send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
261
|
+
continue
|
|
262
|
+
handle(msg)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
main()
|
package/metrics/client.py
CHANGED
|
@@ -343,8 +343,29 @@ def output(res: dict, timeout: float) -> str:
|
|
|
343
343
|
return res["error"] or (res["text"] + "\n" if res["text"] else "")
|
|
344
344
|
|
|
345
345
|
|
|
346
|
+
LOG_LOCKED = "sqlite3.OperationalError: database is locked"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def llm_log_failed_only(stdout: str, stderr: str) -> bool:
|
|
350
|
+
"""True when a non-zero `llm -m` exit is only llm failing to write its own
|
|
351
|
+
log after printing the answer. Seats called at once each open llm's
|
|
352
|
+
logs.db, and the loser's crash (2026-10-22, inkling-small-free answered
|
|
353
|
+
"pong") turned an answer into an error. llm writes the log only once the
|
|
354
|
+
response is complete, so stdout is the whole answer; the same lock in the
|
|
355
|
+
migration llm runs before the call leaves stdout empty and stays an error.
|
|
356
|
+
Not `llm -n`: metrics/cost.py reads that log for token counts."""
|
|
357
|
+
lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
|
|
358
|
+
return bool((stdout or "").strip()) and bool(lines) and lines[-1] == LOG_LOCKED
|
|
359
|
+
|
|
360
|
+
|
|
346
361
|
def _selftest() -> None:
|
|
347
|
-
"""The Responses stream parser: an answer, a mid-stream usage limit, a
|
|
362
|
+
"""The Responses stream parser: an answer, a mid-stream usage limit, a
|
|
363
|
+
cut-off stream; and which `llm` failures are only its log."""
|
|
364
|
+
tb = "Traceback (most recent call last):\n File \"llm/cli.py\", in prompt\n" + LOG_LOCKED + "\n"
|
|
365
|
+
assert llm_log_failed_only("pong\n", tb)
|
|
366
|
+
assert not llm_log_failed_only("", tb) # locked before the call
|
|
367
|
+
assert not llm_log_failed_only("half an ans", "openai.APIError: stream cut\n")
|
|
368
|
+
assert not llm_log_failed_only("pong", tb + "Error: something after\n")
|
|
348
369
|
ok = (b'event: x\ndata: {"type":"response.output_text.delta","delta":"re"}\n\n'
|
|
349
370
|
b'data: {"type":"response.completed","response":{"model":"m","usage":{"total_tokens":3},'
|
|
350
371
|
b'"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"ready"}]}]}}\n')
|
package/metrics/route.py
CHANGED
|
@@ -136,7 +136,12 @@ def full_tag_of(alias: str) -> str:
|
|
|
136
136
|
"""
|
|
137
137
|
from run import _registered_names
|
|
138
138
|
import endpoints
|
|
139
|
-
|
|
139
|
+
# Tags are keyed by the served name. A decision model is not in the llm
|
|
140
|
+
# yaml, so its served name comes from config/decision-models.json.
|
|
141
|
+
served = (_registered_names().get(alias)
|
|
142
|
+
or (_json(DATA / "config" / "decision-models.json").get(alias) or {}).get("served")
|
|
143
|
+
or alias)
|
|
144
|
+
tag = _json(DATA / "config" / "provider-tags.json").get(served)
|
|
140
145
|
if not tag:
|
|
141
146
|
name = endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
|
|
142
147
|
tag = name if name and not endpoints.hub(name) else None
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Wendell Research",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"bin/",
|
|
34
34
|
"serve.py",
|
|
35
35
|
"gateway.py",
|
|
36
|
+
"mcp_server.py",
|
|
36
37
|
"metrics/*.py",
|
|
37
38
|
"scripts/*.py",
|
|
38
39
|
"suite/*.py",
|
package/scripts/check.py
CHANGED
|
@@ -57,6 +57,8 @@ CHECKS = [
|
|
|
57
57
|
("scripts/test_auth.py", [], False),
|
|
58
58
|
("scripts/test_first_run.py", [], False),
|
|
59
59
|
("scripts/test_no_owui.py", [], False),
|
|
60
|
+
("scripts/test_llm_log.py", [], False),
|
|
61
|
+
("scripts/test_mcp.py", [], False),
|
|
60
62
|
("suite/test_score.py", [], False),
|
|
61
63
|
("suite/test_mine.py", [], False),
|
|
62
64
|
("suite/test_seed.py", [], False),
|