@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 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.1
1
+ 0.2.0
@@ -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
- if (command !== 'init' && !fs.existsSync(home)) {
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
- error = (done.stderr.strip() or f"llm exited {done.returncode}") if done.returncode else ""
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))[:s["max_calls"]]
432
- n_explorers = bench_collections.explorers_for(len(members), s)
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 = [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
438
- for a in group if pictures and (why := cannot_see(a))]
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, as they cannot be sent the images: "
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
- "`python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
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
- "verdict": f"POST http://127.0.0.1:{PORT}/bench/verdict"
510
- ' {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
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 (reply or collection.get("reply") or "all") == "merged":
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 cut-off stream."""
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
- tag = _json(DATA / "config" / "provider-tags.json").get(_registered_names().get(alias, alias))
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.1.1",
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),