@wendellresearch-ai/benchwarmers 0.3.1 → 0.3.3
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 +9 -1
- package/README.md +55 -18
- package/VERSION +1 -1
- package/gateway.py +111 -2
- package/mcp_server.py +133 -36
- package/metrics/guard.py +8 -1
- package/package.json +1 -1
- package/scripts/test_mcp.py +207 -2
- package/scripts/test_verdict.py +18 -1
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/README.md
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
</h1>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<a href="https://github.com/WendellResearch-AI/benchwarmers/releases"><img src="https://img.shields.io/github/v/release/WendellResearch-AI/benchwarmers?color=
|
|
9
|
+
<a href="https://github.com/WendellResearch-AI/benchwarmers/releases/latest"><img src="https://img.shields.io/github/v/release/WendellResearch-AI/benchwarmers?logo=github&color=181717" alt="Latest release"></a>
|
|
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
|
-
<a href="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml"><img src="https://
|
|
12
|
+
<a href="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml"><img src="https://img.shields.io/github/actions/workflow/status/WendellResearch-AI/benchwarmers/check.yml?branch=main&logo=github&label=CI%20tests" alt="CI tests"></a>
|
|
13
|
+
<a href="https://registry.modelcontextprotocol.io/?q=benchwarmers"><img src="https://img.shields.io/badge/MCP_Registry-111111?logo=modelcontextprotocol&logoColor=white" alt="In the official MCP Registry"></a>
|
|
13
14
|
<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
15
|
<br>
|
|
15
16
|
<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>
|
|
@@ -17,7 +18,6 @@
|
|
|
17
18
|
<a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Gemini-111111" alt="Gemini API"></a>
|
|
18
19
|
<a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Ollama-111111" alt="Ollama API"></a>
|
|
19
20
|
<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>
|
|
21
21
|
<br>
|
|
22
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>
|
|
23
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>
|
|
@@ -279,28 +279,47 @@ no server at `BENCHWARMERS_URL`.
|
|
|
279
279
|
|
|
280
280
|
## From Claude Code and other agents
|
|
281
281
|
|
|
282
|
-
|
|
283
|
-
`
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
289
290
|
|
|
290
|
-
**Claude Code plugin.** The plugin
|
|
291
|
-
<collection> <what to ask>` command and a `benchwarmers`
|
|
292
|
-
panel helps (adversarial review, exhaustive enumeration
|
|
293
|
-
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:
|
|
294
295
|
|
|
295
296
|
/plugin marketplace add WendellResearch-AI/benchwarmers
|
|
296
297
|
/plugin install benchwarmers@benchwarmers
|
|
297
298
|
|
|
298
|
-
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:
|
|
299
313
|
|
|
300
|
-
|
|
301
|
-
|
|
314
|
+
```json
|
|
315
|
+
{"mcpServers": {"benchwarmers": {"type": "http", "url": "http://127.0.0.1:8122/mcp"}}}
|
|
316
|
+
```
|
|
317
|
+
|
|
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
|
|
302
321
|
`npx -y @wendellresearch-ai/benchwarmers mcp` without installing, or `python mcp_server.py` from a
|
|
303
|
-
checkout. For example:
|
|
322
|
+
checkout, a small process per client that calls the same server. For example:
|
|
304
323
|
|
|
305
324
|
```json
|
|
306
325
|
{"mcpServers": {"benchwarmers": {"command": "npx", "args": ["-y", "@wendellresearch-ai/benchwarmers", "mcp"],
|
|
@@ -316,6 +335,24 @@ On native Windows, a client that cannot start `npx` itself needs
|
|
|
316
335
|
| `BENCHWARMERS_KEY` | a gateway key from Settings, sent as `Authorization: Bearer`; needed for a server on another machine, or where keys are required |
|
|
317
336
|
| `BENCHWARMERS_TIMEOUT` | seconds to wait for a panel, default 900 |
|
|
318
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
|
+
|
|
319
356
|
**An agent running on a collection.** The MCP tools ask a panel for a
|
|
320
357
|
second opinion; an agent can also *be driven by* a collection, in **route
|
|
321
358
|
mode** (`bench/<collection>:route`): one member, picked by its record, gets
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.3
|
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 "")
|
package/mcp_server.py
CHANGED
|
@@ -15,7 +15,8 @@ the verdicts are that server's, whichever machine it is on:
|
|
|
15
15
|
BENCHWARMERS_TIMEOUT seconds to wait for a panel, default 900
|
|
16
16
|
|
|
17
17
|
Tools: list_collections (GET /v1/models), ask (POST /v1/chat/completions),
|
|
18
|
-
verdict (POST /bench/verdict)
|
|
18
|
+
verdict (POST /bench/verdict), and pending_verdicts (POST /bench/pending) for
|
|
19
|
+
the plugin's Stop hook. A refusal from the server, or no server at
|
|
19
20
|
all, comes back as a tool error carrying its message. Standard library only,
|
|
20
21
|
and nothing from metrics/: the data folder is the server's, not this one's.
|
|
21
22
|
"""
|
|
@@ -24,6 +25,7 @@ from __future__ import annotations
|
|
|
24
25
|
|
|
25
26
|
import json
|
|
26
27
|
import os
|
|
28
|
+
import re
|
|
27
29
|
import sys
|
|
28
30
|
import threading
|
|
29
31
|
import urllib.error
|
|
@@ -90,6 +92,16 @@ TOOLS = [
|
|
|
90
92
|
"description": "Claims it made that are untrue, one sentence each."}}},
|
|
91
93
|
"annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True,
|
|
92
94
|
"openWorldHint": False}},
|
|
95
|
+
{"name": "pending_verdicts",
|
|
96
|
+
"title": "Seats still to judge",
|
|
97
|
+
"description": "For the Benchwarmers Claude Code plugin's Stop hook, which calls it at the end of a"
|
|
98
|
+
" turn: the seats of this session's panels that have no verdict yet. You do not need to"
|
|
99
|
+
" call it; send verdicts with `verdict`.",
|
|
100
|
+
"inputSchema": {"type": "object", "additionalProperties": False,
|
|
101
|
+
"properties": {"session": {"type": "string", "description": "The Claude Code session id."},
|
|
102
|
+
"transcript": {"type": "string", "description": "The session's transcript_path."},
|
|
103
|
+
"active": {"description": "The hook's stop_hook_active: true says nothing."}}},
|
|
104
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": False}},
|
|
93
105
|
]
|
|
94
106
|
|
|
95
107
|
|
|
@@ -102,11 +114,32 @@ def base_url() -> str:
|
|
|
102
114
|
return url.removesuffix("/v1")
|
|
103
115
|
|
|
104
116
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
# (server, headers for it): where a tool's calls go.
|
|
118
|
+
Conn = tuple[str, dict]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def env_conn() -> Conn:
|
|
122
|
+
"""The stdio server's: BENCHWARMERS_URL and BENCHWARMERS_KEY. Served over HTTP
|
|
123
|
+
(gateway.py's /mcp), the server's own address and the caller's credential instead."""
|
|
124
|
+
key = (os.environ.get("BENCHWARMERS_KEY") or "").strip()
|
|
125
|
+
return base_url(), {"Authorization": f"Bearer {key}"} if key else {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# A seat's call id, as ask returns it (gateway.py: p<date>-<time>-<hex>.<seat>); only ids the
|
|
129
|
+
# call log knows count, so a loose match costs nothing.
|
|
130
|
+
CALL_ID = re.compile(r"\bp\d{8}-\d{6}-[0-9a-f]{4}\.[a-z]+\d*\b")
|
|
131
|
+
TRANSCRIPT_TAIL = 8 * 1024 * 1024 # bytes of a transcript read: its recent panels
|
|
132
|
+
# Per Claude Code session, the seats pending_verdicts already reminded it of. ponytail: in memory
|
|
133
|
+
# and capped, so a restart reminds once more; keep it in the data folder if that ever matters.
|
|
134
|
+
REMINDED: dict[str, set] = {}
|
|
135
|
+
REMINDED_LOCK = threading.Lock()
|
|
136
|
+
MAX_SESSIONS = 1000
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def http(conn: Conn, method: str, path: str, body: dict | None = None) -> dict:
|
|
140
|
+
base, extra = conn
|
|
141
|
+
url = base + path
|
|
142
|
+
headers = {"Accept": "application/json", **extra}
|
|
110
143
|
data = None
|
|
111
144
|
if body is not None:
|
|
112
145
|
data = json.dumps(body).encode()
|
|
@@ -126,21 +159,21 @@ def http(method: str, path: str, body: dict | None = None) -> dict:
|
|
|
126
159
|
except (urllib.error.URLError, ConnectionError) as e:
|
|
127
160
|
reason = getattr(e, "reason", e)
|
|
128
161
|
if isinstance(reason, TimeoutError):
|
|
129
|
-
raise ToolError(f"no answer from Benchwarmers at {
|
|
162
|
+
raise ToolError(f"no answer from Benchwarmers at {base} in {timeout:.0f} s;"
|
|
130
163
|
" set BENCHWARMERS_TIMEOUT for longer panels") from None
|
|
131
|
-
raise ToolError(f"Benchwarmers is not running at {
|
|
164
|
+
raise ToolError(f"Benchwarmers is not running at {base} ({reason}). Start it (`benchwarmers`,"
|
|
132
165
|
" or its service), or point BENCHWARMERS_URL at the server you use.") from None
|
|
133
166
|
except TimeoutError:
|
|
134
|
-
raise ToolError(f"no answer from Benchwarmers at {
|
|
167
|
+
raise ToolError(f"no answer from Benchwarmers at {base} in {timeout:.0f} s;"
|
|
135
168
|
" set BENCHWARMERS_TIMEOUT for longer panels") from None
|
|
136
169
|
except ValueError:
|
|
137
|
-
raise ToolError(f"{
|
|
170
|
+
raise ToolError(f"{base} answered {method} {path} with something that is not JSON;"
|
|
138
171
|
" is BENCHWARMERS_URL a Benchwarmers server?") from None
|
|
139
172
|
|
|
140
173
|
|
|
141
|
-
def list_collections(_args: dict) -> list[str]:
|
|
174
|
+
def list_collections(_args: dict, conn: Conn) -> list[str]:
|
|
142
175
|
rows = []
|
|
143
|
-
for m in http("GET", "/v1/models").get("data") or []:
|
|
176
|
+
for m in http(conn, "GET", "/v1/models").get("data") or []:
|
|
144
177
|
if ":" in m.get("id", ""): # the same collection in its other reply mode
|
|
145
178
|
continue
|
|
146
179
|
together = m.get("together") or {}
|
|
@@ -153,7 +186,7 @@ def list_collections(_args: dict) -> list[str]:
|
|
|
153
186
|
return [json.dumps(rows, indent=1, ensure_ascii=False)]
|
|
154
187
|
|
|
155
188
|
|
|
156
|
-
def ask(args: dict) -> list[str]:
|
|
189
|
+
def ask(args: dict, conn: Conn) -> list[str]:
|
|
157
190
|
collection, prompt = args.get("collection"), args.get("prompt")
|
|
158
191
|
if not (isinstance(collection, str) and collection.strip()):
|
|
159
192
|
raise ToolError("`collection` is required: a name from list_collections, e.g. core")
|
|
@@ -168,7 +201,7 @@ def ask(args: dict) -> list[str]:
|
|
|
168
201
|
body["bench"] = {"reply": "merged" if args["merged"] else "all"}
|
|
169
202
|
if args.get("with"):
|
|
170
203
|
body["with"] = args["with"]
|
|
171
|
-
reply = http("POST", "/v1/chat/completions", body)
|
|
204
|
+
reply = http(conn, "POST", "/v1/chat/completions", body)
|
|
172
205
|
content = reply["choices"][0]["message"]["content"]
|
|
173
206
|
bench = reply.get("bench") or {}
|
|
174
207
|
merged = bench.get("reply") == "merged"
|
|
@@ -185,19 +218,59 @@ def ask(args: dict) -> list[str]:
|
|
|
185
218
|
+ json.dumps(data, indent=1, ensure_ascii=False)]
|
|
186
219
|
|
|
187
220
|
|
|
188
|
-
def
|
|
221
|
+
def pending_verdicts(args: dict, conn: Conn) -> list[str]:
|
|
222
|
+
"""For the plugin's Stop hook: the seats of this session's panels with no verdict yet, as the
|
|
223
|
+
hook's feedback for Claude (Claude Code reads it as a command hook's stdout), once per seat;
|
|
224
|
+
nothing when all are judged, already reminded of, or the hook is already continuing a turn.
|
|
225
|
+
|
|
226
|
+
The session's panels are the call ids in its transcript (the hook's transcript_path): Claude
|
|
227
|
+
Code keeps no Mcp-Session-Id, so the transcript is the one record of which panels were whose,
|
|
228
|
+
a subagent's included once its report names them. Read by whichever process runs this: the
|
|
229
|
+
stdio server always can, a server on another machine cannot, and then this says nothing.
|
|
230
|
+
Which of those ids are unjudged seats that answered is the server's call log, so a verdict
|
|
231
|
+
sent any way counts."""
|
|
232
|
+
session, path = args.get("session"), args.get("transcript")
|
|
233
|
+
if str(args.get("active")).lower() == "true" or not (isinstance(session, str) and isinstance(path, str)) \
|
|
234
|
+
or not path.endswith(".jsonl"):
|
|
235
|
+
return [""]
|
|
236
|
+
try:
|
|
237
|
+
with open(path, "rb") as fh:
|
|
238
|
+
fh.seek(max(0, os.fstat(fh.fileno()).st_size - TRANSCRIPT_TAIL))
|
|
239
|
+
calls = list(dict.fromkeys(CALL_ID.findall(fh.read().decode("utf-8", "replace"))))
|
|
240
|
+
except OSError:
|
|
241
|
+
return [""]
|
|
242
|
+
if not calls:
|
|
243
|
+
return [""]
|
|
244
|
+
pending = http(conn, "POST", "/bench/pending", {"calls": calls}).get("pending") or []
|
|
245
|
+
with REMINDED_LOCK:
|
|
246
|
+
if session not in REMINDED and len(REMINDED) >= MAX_SESSIONS:
|
|
247
|
+
REMINDED.pop(next(iter(REMINDED))) # the oldest session
|
|
248
|
+
reminded = REMINDED.setdefault(session, set())
|
|
249
|
+
due = [c for c in pending if c not in reminded]
|
|
250
|
+
reminded.update(due)
|
|
251
|
+
if not due:
|
|
252
|
+
return [""]
|
|
253
|
+
return [json.dumps({"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": (
|
|
254
|
+
f"Benchwarmers: {len(due)} seat{'s' * (len(due) != 1)} from this session's panels"
|
|
255
|
+
f" {'have' if len(due) != 1 else 'has'} no verdict yet: {', '.join(due)}. Send a verdict for each"
|
|
256
|
+
" seat you judged (the verdict tool, with its true claims in `real` and untrue ones in `false`),"
|
|
257
|
+
" or say in one line why not.")}})]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def verdict(args: dict, conn: Conn) -> list[str]:
|
|
189
261
|
if not (isinstance(args.get("call"), str) and args["call"].strip()):
|
|
190
262
|
raise ToolError("`call` is required: the seat's call id from ask")
|
|
191
263
|
if not isinstance(args.get("useful"), bool):
|
|
192
264
|
raise ToolError("`useful` must be true or false")
|
|
193
265
|
body = {"call": args["call"].strip(), "useful": args["useful"], "note": args.get("note") or "",
|
|
194
266
|
"real": args.get("real") or [], "false": args.get("false") or []}
|
|
195
|
-
http("POST", "/bench/verdict", body)
|
|
267
|
+
http(conn, "POST", "/bench/verdict", body)
|
|
196
268
|
return [f"Recorded {body['call']}: {'useful' if body['useful'] else 'not useful'},"
|
|
197
269
|
f" {len(body['real'])} real, {len(body['false'])} false."]
|
|
198
270
|
|
|
199
271
|
|
|
200
|
-
HANDLERS = {"list_collections": list_collections, "ask": ask, "verdict": verdict
|
|
272
|
+
HANDLERS = {"list_collections": list_collections, "ask": ask, "verdict": verdict,
|
|
273
|
+
"pending_verdicts": pending_verdicts}
|
|
201
274
|
OUT = threading.Lock()
|
|
202
275
|
|
|
203
276
|
|
|
@@ -208,46 +281,70 @@ def send(message: dict) -> None:
|
|
|
208
281
|
sys.stdout.buffer.flush()
|
|
209
282
|
|
|
210
283
|
|
|
211
|
-
def call_tool(rid, params: dict) ->
|
|
284
|
+
def call_tool(rid, params: dict, conn: Conn) -> dict:
|
|
212
285
|
name, args = params.get("name"), params.get("arguments") or {}
|
|
213
|
-
if name not in HANDLERS:
|
|
214
|
-
return
|
|
286
|
+
if not isinstance(name, str) or name not in HANDLERS:
|
|
287
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name!r}"}}
|
|
215
288
|
try:
|
|
216
289
|
if not isinstance(args, dict):
|
|
217
290
|
raise ToolError("arguments must be an object")
|
|
218
|
-
texts, failed = HANDLERS[name](args), False
|
|
291
|
+
texts, failed = HANDLERS[name](args, conn), False
|
|
219
292
|
except ToolError as e:
|
|
220
293
|
texts, failed = [str(e)], True
|
|
221
294
|
except Exception as e: # noqa: BLE001 -- a bad reply must not kill the server
|
|
222
295
|
texts, failed = [f"{type(e).__name__}: {e}"], True
|
|
223
|
-
|
|
224
|
-
|
|
296
|
+
return {"jsonrpc": "2.0", "id": rid,
|
|
297
|
+
"result": {"content": [{"type": "text", "text": t} for t in texts], "isError": failed}}
|
|
225
298
|
|
|
226
299
|
|
|
227
|
-
def
|
|
300
|
+
def answer(msg, conn: Conn) -> dict | None:
|
|
301
|
+
"""The reply to one JSON-RPC message, or None for a notification. Both
|
|
302
|
+
transports: the stdio loop below and the HTTP route (gateway.py, /mcp).
|
|
303
|
+
Never raises: a request left unanswered would hang its client."""
|
|
304
|
+
try:
|
|
305
|
+
return _answer(msg, conn)
|
|
306
|
+
except Exception as e: # noqa: BLE001
|
|
307
|
+
rid = msg.get("id") if isinstance(msg, dict) else None
|
|
308
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32603, "message": f"{type(e).__name__}: {e}"}}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _answer(msg, conn: Conn) -> dict | None:
|
|
228
312
|
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
|
|
229
313
|
rid = msg.get("id") if isinstance(msg, dict) else None
|
|
230
|
-
return
|
|
314
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}}
|
|
231
315
|
if "id" not in msg: # a notification (initialized, cancelled): nothing to answer
|
|
232
|
-
return
|
|
316
|
+
return None
|
|
233
317
|
rid, method, params = msg["id"], msg["method"], msg.get("params") or {}
|
|
318
|
+
if not isinstance(params, dict):
|
|
319
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": "params must be an object"}}
|
|
234
320
|
if method == "initialize":
|
|
235
321
|
asked = params.get("protocolVersion")
|
|
236
|
-
|
|
322
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {
|
|
237
323
|
"protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0],
|
|
238
324
|
"capabilities": {"tools": {"listChanged": False}},
|
|
239
325
|
"serverInfo": {"name": "benchwarmers", "title": "Benchwarmers", "version": VERSION},
|
|
240
|
-
"instructions": RULES}}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
326
|
+
"instructions": RULES}}
|
|
327
|
+
if method == "ping":
|
|
328
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {}}
|
|
329
|
+
if method == "tools/list":
|
|
330
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
|
|
331
|
+
if method == "tools/call":
|
|
332
|
+
return call_tool(rid, params, conn)
|
|
333
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no method {method!r}"}}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def reply_to(msg) -> None:
|
|
337
|
+
if (reply := answer(msg, env_conn())) is not None:
|
|
338
|
+
send(reply)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def handle(msg) -> None:
|
|
342
|
+
if isinstance(msg, dict) and msg.get("method") == "tools/call":
|
|
246
343
|
# A panel takes minutes: on its own thread, so a ping or a second call is answered meanwhile.
|
|
247
344
|
# Not a daemon: when stdin closes, a call already sent (a verdict) still finishes.
|
|
248
|
-
threading.Thread(target=
|
|
345
|
+
threading.Thread(target=reply_to, args=(msg,)).start()
|
|
249
346
|
else:
|
|
250
|
-
|
|
347
|
+
reply_to(msg)
|
|
251
348
|
|
|
252
349
|
|
|
253
350
|
def main() -> None:
|
|
@@ -256,7 +353,7 @@ def main() -> None:
|
|
|
256
353
|
continue
|
|
257
354
|
try:
|
|
258
355
|
msg = json.loads(raw)
|
|
259
|
-
except ValueError:
|
|
356
|
+
except (ValueError, RecursionError): # RecursionError: nesting deeper than json can parse
|
|
260
357
|
send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
261
358
|
continue
|
|
262
359
|
handle(msg)
|
package/metrics/guard.py
CHANGED
|
@@ -220,7 +220,14 @@ def startup(address: str) -> None:
|
|
|
220
220
|
|
|
221
221
|
def gateway_route(path: str) -> bool:
|
|
222
222
|
"""A route the gateway answers, on its own port or the dashboard's."""
|
|
223
|
-
return path.startswith(GATEWAY_ROUTES) or path.rstrip("/") in OLLAMA_ROUTES
|
|
223
|
+
return path.startswith(GATEWAY_ROUTES) or path.rstrip("/") in OLLAMA_ROUTES or mcp_route(path)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def mcp_route(path: str) -> bool:
|
|
227
|
+
"""MCP over HTTP (gateway.py): /mcp, or /v1/mcp for a BENCHWARMERS_URL
|
|
228
|
+
pasted with its /v1; doubled and trailing slashes from joining a URL
|
|
229
|
+
that ends in one are the same route."""
|
|
230
|
+
return re.sub("/{2,}", "/", path.split("?", 1)[0]).rstrip("/") in ("/mcp", "/v1/mcp")
|
|
224
231
|
|
|
225
232
|
|
|
226
233
|
def _sign(expires: int) -> bytes:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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
|
"mcpName": "io.github.WendellResearch-AI/benchwarmers",
|
|
6
6
|
"license": "MIT",
|
package/scripts/test_mcp.py
CHANGED
|
@@ -23,6 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
23
23
|
|
|
24
24
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
25
25
|
SEEN: list[tuple[str, str, dict, str]] = [] # (method, path, body, Authorization)
|
|
26
|
+
JUDGED: set[str] = set() # call ids the stub has a verdict for
|
|
26
27
|
|
|
27
28
|
MODELS = {"object": "list", "data": [
|
|
28
29
|
{"id": "bench/core", "description": "second opinions", "tags": ["general"], "members": ["m1", "m2"],
|
|
@@ -44,7 +45,10 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
44
45
|
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
45
46
|
SEEN.append(("POST", self.path, body, self.headers.get("Authorization", "")))
|
|
46
47
|
if self.path == "/bench/verdict":
|
|
48
|
+
JUDGED.add(body["call"])
|
|
47
49
|
return self._send(200, {"ok": True, "recorded": {"call": body["call"]}})
|
|
50
|
+
if self.path == "/bench/pending":
|
|
51
|
+
return self._send(200, {"pending": [c for c in body["calls"] if c not in JUDGED]})
|
|
48
52
|
if body["model"] == "bench/nope":
|
|
49
53
|
return self._send(404, {"error": {"message": "no collection nope; choose one of bench/core"}})
|
|
50
54
|
time.sleep(1) # a panel takes a while
|
|
@@ -112,7 +116,8 @@ def main() -> int:
|
|
|
112
116
|
assert c.request("ping")["result"] == {}
|
|
113
117
|
|
|
114
118
|
tools = {t["name"]: t for t in c.request("tools/list")["result"]["tools"]}
|
|
115
|
-
assert set(tools) == {"list_collections", "ask", "verdict"}, tools
|
|
119
|
+
assert set(tools) == {"list_collections", "ask", "verdict", "pending_verdicts"}, tools
|
|
120
|
+
assert c.tool("pending_verdicts", active="false")["content"][0]["text"] == "" # no panel yet: nothing
|
|
116
121
|
assert tools["ask"]["inputSchema"]["required"] == ["collection", "prompt"]
|
|
117
122
|
|
|
118
123
|
r = c.tool("list_collections")
|
|
@@ -153,10 +158,40 @@ def main() -> int:
|
|
|
153
158
|
"real": ["a true claim"], "false": ["x"]}), SEEN[-1]
|
|
154
159
|
assert c.tool("verdict", call="p1.member1")["isError"] # useful is required
|
|
155
160
|
|
|
161
|
+
# The Stop hook's tool: the call ids in the session's transcript (each once), the server says which
|
|
162
|
+
# are unjudged, and those come back as feedback for Claude, once; nothing while the hook is already
|
|
163
|
+
# continuing a turn, nor for a transcript it cannot read.
|
|
164
|
+
import tempfile
|
|
165
|
+
a, b = "p20261025-120000-abcd.member1", "p20261025-120000-abcd.explorer2"
|
|
166
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
167
|
+
transcript = pathlib.Path(tmp) / "session.jsonl"
|
|
168
|
+
transcript.write_text(f'{{"tool_result": "`{a}` ok"}}\n{{"tool_result": "`{b}` ok, `{a}` again"}}\n',
|
|
169
|
+
encoding="utf-8")
|
|
170
|
+
hook = {"session": "s", "transcript": str(transcript)}
|
|
171
|
+
assert not c.tool("verdict", call=a, useful=True)["isError"]
|
|
172
|
+
assert c.tool("pending_verdicts", active="true", **hook)["content"][0]["text"] == ""
|
|
173
|
+
out = json.loads(c.tool("pending_verdicts", active="false", **hook)["content"][0]["text"])
|
|
174
|
+
assert SEEN[-1][1:3] == ("/bench/pending", {"calls": [a, b]}), SEEN[-1]
|
|
175
|
+
ctx = out["hookSpecificOutput"]
|
|
176
|
+
assert ctx["hookEventName"] == "Stop" and "1 seat " in ctx["additionalContext"], ctx
|
|
177
|
+
assert b in ctx["additionalContext"] and a not in ctx["additionalContext"], ctx
|
|
178
|
+
assert c.tool("pending_verdicts", active="false", **hook)["content"][0]["text"] == "" # reminded
|
|
179
|
+
assert c.tool("pending_verdicts", session="t", transcript=str(transcript) + ".missing.jsonl",
|
|
180
|
+
active="false")["content"][0]["text"] == ""
|
|
181
|
+
assert c.tool("pending_verdicts", session="t", transcript=str(pathlib.Path(tmp) / "x.txt"),
|
|
182
|
+
active="false")["content"][0]["text"] == "" # not a transcript
|
|
183
|
+
|
|
156
184
|
assert c.request("tools/call", {"name": "nope"})["error"]["code"] == -32602
|
|
185
|
+
assert c.request("tools/call", {"name": ["x"]})["error"]["code"] == -32602 # unhashable: answered
|
|
157
186
|
assert c.request("resources/list")["error"]["code"] == -32601
|
|
158
187
|
c.send("{not json")
|
|
159
188
|
assert c.read()["error"]["code"] == -32700
|
|
189
|
+
c.send("[" * 100_000) # deeper than json parses
|
|
190
|
+
assert c.read()["error"]["code"] == -32700
|
|
191
|
+
for method in ("initialize", "tools/call"): # params not an object: answered, never raised
|
|
192
|
+
c.send({"jsonrpc": "2.0", "id": method, "method": method, "params": [1]})
|
|
193
|
+
assert c.read() == {"jsonrpc": "2.0", "id": method,
|
|
194
|
+
"error": {"code": -32602, "message": "params must be an object"}}
|
|
160
195
|
c.close()
|
|
161
196
|
|
|
162
197
|
# An unknown protocol version gets the newest; no server running is a clear tool error.
|
|
@@ -170,9 +205,179 @@ def main() -> int:
|
|
|
170
205
|
assert r["isError"] and "not running" in r["content"][0]["text"], r
|
|
171
206
|
c.close()
|
|
172
207
|
stub.shutdown()
|
|
173
|
-
|
|
208
|
+
over_http()
|
|
209
|
+
print("mcp server ok: initialize, ping, tools/list, each tool, refusals, a ping mid-panel, not running;"
|
|
210
|
+
" /mcp on both ports, its paths, the key and its scope through the loopback, the guard;"
|
|
211
|
+
" pending_verdicts and /bench/pending for the Stop hook")
|
|
174
212
|
return 0
|
|
175
213
|
|
|
176
214
|
|
|
215
|
+
def over_http() -> None:
|
|
216
|
+
"""POST /mcp on the real handlers, both ports: the tools reach the server's own routes with the caller's key."""
|
|
217
|
+
import tempfile
|
|
218
|
+
import urllib.error
|
|
219
|
+
import urllib.request
|
|
220
|
+
sys.path.insert(0, str(ROOT))
|
|
221
|
+
import serve
|
|
222
|
+
import gateway
|
|
223
|
+
import guard
|
|
224
|
+
|
|
225
|
+
def post(port, path, msg, **headers):
|
|
226
|
+
headers = {"Host": f"127.0.0.1:{port}", "Content-Type": "application/json", **headers}
|
|
227
|
+
data = msg.encode() if isinstance(msg, str) else json.dumps(msg).encode()
|
|
228
|
+
req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", data, headers,
|
|
229
|
+
method="POST" if msg is not None else "GET")
|
|
230
|
+
try:
|
|
231
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
232
|
+
raw = r.read()
|
|
233
|
+
return r.status, json.loads(raw) if raw else None
|
|
234
|
+
except urllib.error.HTTPError as e:
|
|
235
|
+
return e.code, json.loads(e.read() or b"null")
|
|
236
|
+
|
|
237
|
+
def tool(port, name, path="/mcp", **headers_and_args):
|
|
238
|
+
headers = {k: headers_and_args.pop(k) for k in ("Authorization", "Origin") if k in headers_and_args}
|
|
239
|
+
status, reply = post(port, path, {"jsonrpc": "2.0", "id": 7, "method": "tools/call",
|
|
240
|
+
"params": {"name": name, "arguments": headers_and_args}}, **headers)
|
|
241
|
+
assert status == 200 and reply["id"] == 7, (status, reply)
|
|
242
|
+
return reply["result"]
|
|
243
|
+
|
|
244
|
+
servers = [ThreadingHTTPServer(("127.0.0.1", 0), h) for h in (serve.Handler, gateway.Handler)]
|
|
245
|
+
for s in servers:
|
|
246
|
+
threading.Thread(target=s.serve_forever, daemon=True).start()
|
|
247
|
+
saved = (guard.KEYS, gateway.bench_collections.FILE, gateway.run_collection, sys.stderr,
|
|
248
|
+
os.environ.pop("BENCHWARMERS_ADMIN_TOKEN", None), os.environ.pop("BENCHWARMERS_REQUIRE_KEYS", None),
|
|
249
|
+
gateway.offload.LOG)
|
|
250
|
+
ran = []
|
|
251
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
252
|
+
try:
|
|
253
|
+
t = pathlib.Path(tmp)
|
|
254
|
+
guard.KEYS, gateway.bench_collections.FILE = t / "gateway-keys.json", t / "collections.json"
|
|
255
|
+
gateway.bench_collections.FILE.write_text(json.dumps({"core": {"members": [], "description": "x"},
|
|
256
|
+
"vision": {"members": [], "description": "y"}}),
|
|
257
|
+
encoding="utf-8")
|
|
258
|
+
gateway.offload.LOG = t / "offloads.jsonl"
|
|
259
|
+
gateway.run_collection = lambda name, messages, nominated, reply, who: (
|
|
260
|
+
ran.append((name, who)) or f"answer from {name}",
|
|
261
|
+
{"seats": [{"call": f"{name}.member1", "seat": "member", "alias": "m1", "status": "ok"},
|
|
262
|
+
{"call": f"{name}.explorer", "seat": "explorer", "alias": "x1", "status": "error"}]})
|
|
263
|
+
sys.stderr = open(os.devnull, "w") # the access log
|
|
264
|
+
for port in (s.server_port for s in servers):
|
|
265
|
+
init = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
266
|
+
"params": {"protocolVersion": "2025-06-18", "capabilities": {}}}
|
|
267
|
+
# A BENCHWARMERS_URL with /v1 or a trailing slash, joined to /mcp, is the same route.
|
|
268
|
+
for path in ("/mcp", "/mcp/", "//mcp", "/v1/mcp", "/v1//mcp"):
|
|
269
|
+
status, reply = post(port, path, init)
|
|
270
|
+
assert status == 200 and reply["result"]["serverInfo"]["name"] == "benchwarmers", (path, reply)
|
|
271
|
+
assert not guard.mcp_route("/mcpx") and not guard.mcp_route("/v1/mcp/x")
|
|
272
|
+
assert post(port, "/mcp", {"jsonrpc": "2.0", "method": "notifications/initialized"}) == (202, None)
|
|
273
|
+
assert post(port, "/mcp", None)[0] == 405 # no server stream
|
|
274
|
+
assert post(port, "/mcp", "{not json") == (400, {"jsonrpc": "2.0", "id": None,
|
|
275
|
+
"error": {"code": -32700, "message": "parse error"}})
|
|
276
|
+
status, reply = post(port, "/mcp", {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
|
277
|
+
assert {x["name"] for x in reply["result"]["tools"]} == {"list_collections", "ask", "verdict",
|
|
278
|
+
"pending_verdicts"}
|
|
279
|
+
r = tool(port, "list_collections", path="//mcp")
|
|
280
|
+
assert not r["isError"] and [x["name"] for x in json.loads(r["content"][0]["text"])] == \
|
|
281
|
+
["bench/core", "bench/vision"], r
|
|
282
|
+
r = tool(port, "ask", collection="core", prompt="hi")
|
|
283
|
+
assert not r["isError"] and "answer from core" in r["content"][0]["text"], r
|
|
284
|
+
# A client that takes SSE (every streamable-HTTP client must) gets a tool call as a stream,
|
|
285
|
+
# keep-alives while it runs and then the reply, so its idle timeouts never fire mid-panel.
|
|
286
|
+
saved_keepalive, gateway.KEEPALIVE = gateway.KEEPALIVE, 0.2
|
|
287
|
+
slow, gateway.run_collection = gateway.run_collection, lambda *a, **k: (time.sleep(0.7), slow(*a, **k))[1]
|
|
288
|
+
req = urllib.request.Request(
|
|
289
|
+
f"http://127.0.0.1:{port}/mcp", json.dumps({"jsonrpc": "2.0", "id": 9, "method": "tools/call",
|
|
290
|
+
"params": {"name": "ask", "arguments": {
|
|
291
|
+
"collection": "core", "prompt": "hi"}}}).encode(),
|
|
292
|
+
{"Host": f"127.0.0.1:{port}", "Content-Type": "application/json",
|
|
293
|
+
"Accept": "application/json, text/event-stream"})
|
|
294
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
295
|
+
assert resp.headers["Content-Type"] == "text/event-stream", resp.headers
|
|
296
|
+
events = resp.read().decode()
|
|
297
|
+
assert events.startswith(": keep-alive\n\n") and events.endswith("\n\n"), events
|
|
298
|
+
reply = json.loads(events.rsplit("data: ", 1)[1])
|
|
299
|
+
assert reply["id"] == 9 and "answer from core" in reply["result"]["content"][0]["text"], reply
|
|
300
|
+
# With a progressToken, progress notifications instead: what resets a client's idle timeout.
|
|
301
|
+
req = urllib.request.Request(
|
|
302
|
+
f"http://127.0.0.1:{port}/mcp", json.dumps({"jsonrpc": "2.0", "id": 11, "method": "tools/call",
|
|
303
|
+
"params": {"name": "ask", "_meta": {"progressToken": "t1"},
|
|
304
|
+
"arguments": {"collection": "core",
|
|
305
|
+
"prompt": "hi"}}}).encode(),
|
|
306
|
+
{"Host": f"127.0.0.1:{port}", "Content-Type": "application/json",
|
|
307
|
+
"Accept": "application/json, text/event-stream"})
|
|
308
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
309
|
+
messages = [json.loads(line[6:]) for line in resp.read().decode().splitlines()
|
|
310
|
+
if line.startswith("data: ")]
|
|
311
|
+
gateway.KEEPALIVE, gateway.run_collection = saved_keepalive, slow
|
|
312
|
+
assert ": keep-alive" not in json.dumps(messages) and len(messages) >= 2, messages
|
|
313
|
+
assert all(m["method"] == "notifications/progress" and m["params"]["progressToken"] == "t1"
|
|
314
|
+
for m in messages[:-1]), messages
|
|
315
|
+
assert messages[-1]["id"] == 11 and not messages[-1]["result"]["isError"], messages[-1]
|
|
316
|
+
# A tool call that cannot be answered still ends its stream (it once kept it open forever).
|
|
317
|
+
req = urllib.request.Request(
|
|
318
|
+
f"http://127.0.0.1:{port}/mcp", json.dumps({"jsonrpc": "2.0", "id": 10, "method": "tools/call",
|
|
319
|
+
"params": [1]}).encode(),
|
|
320
|
+
{"Host": f"127.0.0.1:{port}", "Content-Type": "application/json",
|
|
321
|
+
"Accept": "application/json, text/event-stream"})
|
|
322
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
323
|
+
reply = json.loads(resp.read().decode().rsplit("data: ", 1)[1])
|
|
324
|
+
assert reply["id"] == 10 and reply["error"]["code"] == -32602, reply
|
|
325
|
+
assert post(port, "/mcp", "[" * 100_000)[1]["error"]["code"] == -32700
|
|
326
|
+
# The guard on /mcp itself: Host and Origin, as on /v1.
|
|
327
|
+
assert post(port, "/mcp", init, Host="evil.example")[0] == 403
|
|
328
|
+
assert post(port, "/mcp", init, Origin="http://evil.example")[0] == 403
|
|
329
|
+
assert ran == [("core", "local")] * 6, ran
|
|
330
|
+
|
|
331
|
+
# The Stop hook over HTTP: the call ids in the transcript, of which the call log says which are
|
|
332
|
+
# seats that answered (ok or empty, not errored) and have no verdict; reminded of once; a
|
|
333
|
+
# verdict sent any way clears one; an id the log does not know is not a seat.
|
|
334
|
+
g = servers[1].server_port
|
|
335
|
+
ok, empty, failed, judged, unknown = (f"p20261025-130000-beef.{s}" for s in
|
|
336
|
+
("member1", "member2", "explorer", "explorer2", "member9"))
|
|
337
|
+
for call, note in ((ok, "member; ok in 1.0s"), (empty, "member; empty in 2.0s"),
|
|
338
|
+
(failed, "explorer; error in 0.4s: Error: 429"), (judged, "explorer 2; ok in 1.0s")):
|
|
339
|
+
gateway.offload.add("m", "t", None, note, call)
|
|
340
|
+
gateway.offload.add("m", "t", True, "checked", judged)
|
|
341
|
+
status, reply = post(g, "/bench/pending", {"calls": [ok, empty, failed, judged, unknown]})
|
|
342
|
+
assert (status, reply) == (200, {"pending": [ok, empty]}), reply
|
|
343
|
+
assert post(g, "/bench/pending", {"calls": "x"})[0] == 400
|
|
344
|
+
transcript = t / "claude-1.jsonl"
|
|
345
|
+
transcript.write_text(" ".join((ok, empty, failed, judged, unknown, ok)), encoding="utf-8")
|
|
346
|
+
|
|
347
|
+
def pending(session="claude-1"):
|
|
348
|
+
return tool(g, "pending_verdicts", session=session, transcript=str(transcript),
|
|
349
|
+
active="false")["content"][0]["text"]
|
|
350
|
+
ctx = json.loads(pending())["hookSpecificOutput"]["additionalContext"]
|
|
351
|
+
assert "2 seats " in ctx and ok in ctx and empty in ctx and failed not in ctx and judged not in ctx, ctx
|
|
352
|
+
assert pending() == "" # once per session
|
|
353
|
+
assert ok in pending("claude-2") # another session: its own reminder
|
|
354
|
+
assert not tool(g, "verdict", call=ok, useful=True)["isError"]
|
|
355
|
+
assert post(g, "/bench/pending", {"calls": [ok, empty]})[1] == {"pending": [empty]}
|
|
356
|
+
|
|
357
|
+
# Keys required: /mcp asks for one, and the loopback carries it, scope and all.
|
|
358
|
+
os.environ["BENCHWARMERS_REQUIRE_KEYS"] = "1"
|
|
359
|
+
vision = {"Authorization": "Bearer " + guard.make_key("vision-only", ["vision"])}
|
|
360
|
+
ran.clear()
|
|
361
|
+
for port in (s.server_port for s in servers):
|
|
362
|
+
assert post(port, "/mcp", init)[0] == 401
|
|
363
|
+
assert post(port, "/mcp", init, Authorization="Bearer ")[0] == 401 # the plugin with no key set
|
|
364
|
+
r = tool(port, "list_collections", **vision)
|
|
365
|
+
assert [x["name"] for x in json.loads(r["content"][0]["text"])] == ["bench/vision"], r
|
|
366
|
+
r = tool(port, "ask", collection="core", prompt="hi", **vision)
|
|
367
|
+
assert r["isError"] and "may call bench/vision" in r["content"][0]["text"], r
|
|
368
|
+
assert not tool(port, "ask", collection="vision", prompt="hi", **vision)["isError"]
|
|
369
|
+
assert ran == [("vision", "vision-only")] * 2, ran
|
|
370
|
+
finally:
|
|
371
|
+
sys.stderr.close()
|
|
372
|
+
guard.KEYS, gateway.bench_collections.FILE, gateway.run_collection, sys.stderr = saved[:4]
|
|
373
|
+
gateway.offload.LOG = saved[6]
|
|
374
|
+
for name, value in (("BENCHWARMERS_ADMIN_TOKEN", saved[4]), ("BENCHWARMERS_REQUIRE_KEYS", saved[5])):
|
|
375
|
+
os.environ.pop(name, None)
|
|
376
|
+
if value is not None:
|
|
377
|
+
os.environ[name] = value
|
|
378
|
+
for s in servers:
|
|
379
|
+
s.shutdown()
|
|
380
|
+
|
|
381
|
+
|
|
177
382
|
if __name__ == "__main__":
|
|
178
383
|
sys.exit(main())
|
package/scripts/test_verdict.py
CHANGED
|
@@ -96,7 +96,24 @@ def bench_field() -> None:
|
|
|
96
96
|
srv.shutdown()
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def records() -> None:
|
|
100
|
+
"""GET /bench/record's counts: one collection's judged seats, a task's by its notes, never an unjudged one."""
|
|
101
|
+
for call, model, coll, useful, note in [("r1.m1", "m1", "bench/vision", True, "thicken slab faces: agreed"),
|
|
102
|
+
("r1.m2", "m2", "bench/vision", False, "thicken slab faces: no answer"),
|
|
103
|
+
("r2.m1", "m1", "bench/vision", True, "thicken what it is: agreed"),
|
|
104
|
+
("r3.m1", "m1", "bench/core", False, "a review")]:
|
|
105
|
+
offload.add(model, f"look at this ({coll}, vision)", None, "member; ok", call)
|
|
106
|
+
offload.verdict(call, useful, note)
|
|
107
|
+
offload.add("m3", "look at this (bench/vision, vision)", None, "member; ok", "r4.m3") # never judged
|
|
108
|
+
assert gateway.record("bench/vision", "thicken slab faces") == {
|
|
109
|
+
"m1": {"useful": 1, "notUseful": 0, "judged": 1, "lastAt": offload.entries()[-1]["date"]},
|
|
110
|
+
"m2": {"useful": 0, "notUseful": 1, "judged": 1, "lastAt": offload.entries()[-1]["date"]}}
|
|
111
|
+
assert gateway.record("bench/vision")["m1"]["judged"] == 2 and "m3" not in gateway.record("bench/vision")
|
|
112
|
+
assert gateway.record("bench/core")["m1"]["notUseful"] == 1 and gateway.record("bench/none") == {}
|
|
113
|
+
|
|
114
|
+
|
|
99
115
|
if __name__ == "__main__":
|
|
100
116
|
verdicts()
|
|
101
117
|
bench_field()
|
|
102
|
-
|
|
118
|
+
records()
|
|
119
|
+
print("verdict, bench-field and record checks pass")
|