@wendellresearch-ai/benchwarmers 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -8
- package/GATEWAY.md +9 -1
- package/PRIVACY.md +7 -5
- package/README.md +122 -51
- package/SECURITY.md +5 -4
- package/USAGE.md +19 -2
- package/VERSION +1 -1
- package/gateway.py +111 -2
- package/mcp_server.py +133 -36
- package/metrics/client.py +9 -8
- package/metrics/guard.py +8 -1
- package/package.json +1 -1
- package/scripts/test_mcp.py +207 -2
- package/scripts/test_serve_edit.py +2 -2
- package/scripts/test_verdict.py +18 -1
package/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/client.py
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
python metrics/client.py <alias> "prompt" # one call; prints what ask_once would return
|
|
4
4
|
|
|
5
|
-
RELEASE-PLAN.md Phases 2a and 2b. A drop-in for `llm -m <alias>`,
|
|
6
|
-
Settings or
|
|
5
|
+
RELEASE-PLAN.md Phases 2a and 2b. A drop-in for `llm -m <alias>`, and the
|
|
6
|
+
default since v0.3 (`llm` stays a choice on Settings or BENCH_CLIENT=llm; see
|
|
7
|
+
chosen()), used by suite/run.py's
|
|
7
8
|
ask_once, and so the perf probe, the suite, retries and auto-retire's
|
|
8
9
|
confirmation call, and by the gateway's seats. It reads the same
|
|
9
10
|
registration (the yaml entry's model name, base, key name and headers), the
|
|
@@ -118,7 +119,6 @@ import json
|
|
|
118
119
|
import os
|
|
119
120
|
import pathlib
|
|
120
121
|
import random
|
|
121
|
-
import shutil
|
|
122
122
|
import sys
|
|
123
123
|
import threading
|
|
124
124
|
import time
|
|
@@ -146,10 +146,11 @@ CHOICE = DATA / "config" / "client.json" # the Settings page's choice
|
|
|
146
146
|
|
|
147
147
|
def chosen() -> tuple[str, str]:
|
|
148
148
|
"""The configured client and what set it: BENCH_CLIENT ("environment"),
|
|
149
|
-
else the Settings page's choice ("settings"), else ("default")
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
applies to the
|
|
149
|
+
else the Settings page's choice ("settings"), else ("default") the
|
|
150
|
+
built-in client, installed `llm` or not: since v0.3 the `llm` CLI is an
|
|
151
|
+
alternative chosen on Settings, no longer the default (its trial week:
|
|
152
|
+
RELEASE-PLAN Phase 2a). Read on every call, so a change applies to the
|
|
153
|
+
next one."""
|
|
153
154
|
env = (os.environ.get("BENCH_CLIENT") or "").strip().lower()
|
|
154
155
|
if env:
|
|
155
156
|
return env, "environment"
|
|
@@ -157,7 +158,7 @@ def chosen() -> tuple[str, str]:
|
|
|
157
158
|
saved = json.loads(CHOICE.read_text(encoding="utf-8")).get("client")
|
|
158
159
|
except (OSError, ValueError, AttributeError):
|
|
159
160
|
saved = None
|
|
160
|
-
return (saved, "settings") if saved in CLIENTS else ("
|
|
161
|
+
return (saved, "settings") if saved in CLIENTS else ("builtin", "default")
|
|
161
162
|
|
|
162
163
|
|
|
163
164
|
def backend(alias: str = "") -> str:
|
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.2",
|
|
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())
|
|
@@ -291,7 +291,7 @@ def main() -> int:
|
|
|
291
291
|
model_client.CHOICE = cfg / "client.json"
|
|
292
292
|
env, real_which = os.environ.pop("BENCH_CLIENT", None), shutil.which
|
|
293
293
|
try:
|
|
294
|
-
shutil.which = lambda name: "/usr/bin/" + name # llm installed:
|
|
294
|
+
shutil.which = lambda name: "/usr/bin/" + name # llm installed: still built-in
|
|
295
295
|
first = model_client.chosen()
|
|
296
296
|
shutil.which = lambda name: None # not installed (npm, a container)
|
|
297
297
|
bare = model_client.chosen()
|
|
@@ -310,7 +310,7 @@ def main() -> int:
|
|
|
310
310
|
os.environ.pop("BENCH_CLIENT", None)
|
|
311
311
|
if env is not None:
|
|
312
312
|
os.environ["BENCH_CLIENT"] = env
|
|
313
|
-
ok = (first == ("
|
|
313
|
+
ok = (first == ("builtin", "default") and bare == ("builtin", "default") and made["ok"] and picked == (("builtin", "settings"), "builtin")
|
|
314
314
|
and pinned == (("llm", "environment"), False) and not no_llm and not unknown)
|
|
315
315
|
print(("ok " if ok else "FAIL ") + "model client choice" + ("" if ok else f" -> {first} {made} {picked} {pinned}"))
|
|
316
316
|
failures += 0 if ok else 1
|