@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1

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.
Files changed (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +295 -295
  22. package/metrics/client.py +394 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -579
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -65
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -106
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -81
  62. package/scripts/test_mcp.py +178 -178
  63. package/scripts/test_merged.py +214 -214
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -438
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3764
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1042
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -425
  93. package/ui/bench.js +1756 -1576
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
package/mcp_server.py CHANGED
@@ -1,266 +1,266 @@
1
- """Benchwarmers as an MCP server, so an agent (Claude Code, or any MCP client)
2
- can ask a collection and send its verdicts.
3
-
4
- benchwarmers mcp # from npm: npx -y @wendellresearch-ai/benchwarmers mcp
5
- python mcp_server.py # from a checkout
6
-
7
- MCP over stdio: JSON-RPC 2.0, one message per line, nothing else on stdout.
8
- A THIN CLIENT of a Benchwarmers server that is already running, over the same
9
- HTTP routes any other caller uses (GATEWAY.md), so the panel, the call log and
10
- the verdicts are that server's, whichever machine it is on:
11
-
12
- BENCHWARMERS_URL the server, default http://127.0.0.1:8122 (a /v1 on the end is fine)
13
- BENCHWARMERS_KEY a gateway key, sent as `Authorization: Bearer`; needed beyond
14
- this machine, or where BENCHWARMERS_REQUIRE_KEYS is set (metrics/guard.py)
15
- BENCHWARMERS_TIMEOUT seconds to wait for a panel, default 900
16
-
17
- Tools: list_collections (GET /v1/models), ask (POST /v1/chat/completions),
18
- verdict (POST /bench/verdict). A refusal from the server, or no server at
19
- all, comes back as a tool error carrying its message. Standard library only,
20
- and nothing from metrics/: the data folder is the server's, not this one's.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import json
26
- import os
27
- import sys
28
- import threading
29
- import urllib.error
30
- import urllib.request
31
- from pathlib import Path
32
-
33
- # Newest first: the client's version when it is one of these, else the newest.
34
- PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
35
- PREFIX = "bench/"
36
- VERSION = (Path(__file__).resolve().parent / "VERSION").read_text(encoding="utf-8").strip()
37
-
38
- RULES = (
39
- "Benchwarmers asks a COLLECTION of models at once (every member plus explorer seats it rotates in)"
40
- " and keeps every answer. Rules: (1) ask a collection, not a favourite model; list_collections says"
41
- " what each is for. (2) Verify before acting on anything a seat says: seats fail together, so"
42
- " agreement is not evidence. (3) Send a verdict per seat you judged, with the claims it got right"
43
- " in `real` and the untrue ones in `false`. (4) Name every seat that answered, by model and seat,"
44
- " including errors and empty replies. An empty reply from a reasoning model is usually its token"
45
- " budget, not incapacity. Good uses: exhaustive enumeration over real files, adversarial review."
46
- " Keep the design and the decisions yourself.")
47
-
48
- TOOLS = [
49
- {"name": "list_collections",
50
- "title": "List collections",
51
- "description": "The collections this Benchwarmers server offers: name, what each is for, tags, members,"
52
- " explorer seats, reply mode, and what the members handle together (smallest context"
53
- " window, shared capabilities, tightest rate limits). Pick by task before calling ask.",
54
- "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
55
- "annotations": {"readOnlyHint": True, "openWorldHint": False}},
56
- {"name": "ask",
57
- "title": "Ask a collection",
58
- "description": "Put a prompt to every seat of one collection at once and get every answer back, each"
59
- " under its seat and model, plus the seats as data (alias, seat, status, call id). Can"
60
- " take minutes. Ask a collection, not a favourite model. Give the seats the real text"
61
- " (code, diff, file contents) in the prompt: they cannot read your files. Verify every"
62
- " claim against the source before acting on it, name every seat that answered in what"
63
- " you tell the user, then send a verdict per seat you judged.",
64
- "inputSchema": {"type": "object", "required": ["collection", "prompt"], "additionalProperties": False,
65
- "properties": {
66
- "collection": {"type": "string",
67
- "description": "A collection name, `core` or `bench/core` (list_collections)."},
68
- "prompt": {"type": "string", "description": "The question, with everything it needs."},
69
- "system": {"type": "string", "description": "A system message for every seat."},
70
- "merged": {"type": "boolean",
71
- "description": "true: one reconciled answer, every seat still called and"
72
- " listed; false: every answer. Omit for the collection's own."},
73
- "with": {"type": "array", "items": {"type": "string"},
74
- "description": "Model aliases to seat in explorer chairs, this request only."}}},
75
- "annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True}},
76
- {"name": "verdict",
77
- "title": "Record a verdict",
78
- "description": "Judge one seat's answer by its call id, once you have checked it against the source. One"
79
- " per seat you judged. List the claims it got right in `real` and the claims that are"
80
- " untrue in `false`, one sentence each: they feed routing and the 'confidently wrong'"
81
- " record, so a note alone is not enough.",
82
- "inputSchema": {"type": "object", "required": ["call", "useful"], "additionalProperties": False,
83
- "properties": {
84
- "call": {"type": "string", "description": "The seat's call id, e.g. p20261022-101500-ab12.member1."},
85
- "useful": {"type": "boolean"},
86
- "note": {"type": "string", "description": "One or two sentences on why."},
87
- "real": {"type": "array", "items": {"type": "string"},
88
- "description": "Claims it made that are true, one sentence each."},
89
- "false": {"type": "array", "items": {"type": "string"},
90
- "description": "Claims it made that are untrue, one sentence each."}}},
91
- "annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True,
92
- "openWorldHint": False}},
93
- ]
94
-
95
-
96
- class ToolError(Exception):
97
- """A tool call that failed: its message goes back as the tool's result, isError."""
98
-
99
-
100
- def base_url() -> str:
101
- url = (os.environ.get("BENCHWARMERS_URL") or "").strip().rstrip("/") or "http://127.0.0.1:8122"
102
- return url.removesuffix("/v1")
103
-
104
-
105
- def http(method: str, path: str, body: dict | None = None) -> dict:
106
- url = base_url() + path
107
- headers = {"Accept": "application/json"}
108
- if key := (os.environ.get("BENCHWARMERS_KEY") or "").strip():
109
- headers["Authorization"] = f"Bearer {key}"
110
- data = None
111
- if body is not None:
112
- data = json.dumps(body).encode()
113
- headers["Content-Type"] = "application/json"
114
- req = urllib.request.Request(url, data=data, headers=headers, method=method)
115
- timeout = float(os.environ.get("BENCHWARMERS_TIMEOUT") or 900)
116
- try:
117
- with urllib.request.urlopen(req, timeout=timeout) as r:
118
- return json.load(r)
119
- except urllib.error.HTTPError as e:
120
- raw = e.read().decode("utf-8", "replace")
121
- try:
122
- message = json.loads(raw)["error"]["message"]
123
- except (ValueError, KeyError, TypeError):
124
- message = raw.strip()[:500] or e.reason
125
- raise ToolError(f"Benchwarmers refused {method} {path} ({e.code}): {message}") from None
126
- except (urllib.error.URLError, ConnectionError) as e:
127
- reason = getattr(e, "reason", e)
128
- if isinstance(reason, TimeoutError):
129
- raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
130
- " set BENCHWARMERS_TIMEOUT for longer panels") from None
131
- raise ToolError(f"Benchwarmers is not running at {base_url()} ({reason}). Start it (`benchwarmers`,"
132
- " or its service), or point BENCHWARMERS_URL at the server you use.") from None
133
- except TimeoutError:
134
- raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
135
- " set BENCHWARMERS_TIMEOUT for longer panels") from None
136
- except ValueError:
137
- raise ToolError(f"{base_url()} answered {method} {path} with something that is not JSON;"
138
- " is BENCHWARMERS_URL a Benchwarmers server?") from None
139
-
140
-
141
- def list_collections(_args: dict) -> list[str]:
142
- rows = []
143
- for m in http("GET", "/v1/models").get("data") or []:
144
- if ":" in m.get("id", ""): # the same collection in its other reply mode
145
- continue
146
- together = m.get("together") or {}
147
- rows.append({"name": m["id"], **{k: m.get(k) for k in ("description", "tags", "members", "explorers",
148
- "shape", "reply", "merger")},
149
- **{k: together.get(k) for k in ("context_window", "capabilities", "limits")}})
150
- if not rows:
151
- return ["No collections on this server (or none this gateway key may call). Make one on the"
152
- " dashboard's Collections page."]
153
- return [json.dumps(rows, indent=1, ensure_ascii=False)]
154
-
155
-
156
- def ask(args: dict) -> list[str]:
157
- collection, prompt = args.get("collection"), args.get("prompt")
158
- if not (isinstance(collection, str) and collection.strip()):
159
- raise ToolError("`collection` is required: a name from list_collections, e.g. core")
160
- if not (isinstance(prompt, str) and prompt.strip()):
161
- raise ToolError("`prompt` is required")
162
- collection = collection.strip()
163
- messages = [{"role": "user", "content": prompt}]
164
- if isinstance(args.get("system"), str) and args["system"].strip():
165
- messages.insert(0, {"role": "system", "content": args["system"]})
166
- body = {"model": collection if collection.startswith(PREFIX) else PREFIX + collection, "messages": messages}
167
- if isinstance(args.get("merged"), bool):
168
- body["bench"] = {"reply": "merged" if args["merged"] else "all"}
169
- if args.get("with"):
170
- body["with"] = args["with"]
171
- reply = http("POST", "/v1/chat/completions", body)
172
- content = reply["choices"][0]["message"]["content"]
173
- bench = reply.get("bench") or {}
174
- merged = bench.get("reply") == "merged"
175
- # Each answer is already in the content unless it is the merge, so the
176
- # seat data stays short: who sat, how it went, the id to judge it by.
177
- keep = ("call", "seat", "alias", "status", "seconds", "error") + (("answer",) if merged else ())
178
- seats = [{k: s[k] for k in keep if s.get(k) not in (None, "")} for s in bench.get("seats") or []]
179
- for s in seats:
180
- if "error" in s:
181
- s["error"] = s["error"][-300:] # a traceback's last lines say what went wrong
182
- data = {"panel": bench.get("panel"), "collection": bench.get("collection"), "shape": bench.get("shape"),
183
- "seats": seats, **{k: bench[k] for k in ("merger", "skipped", "note") if bench.get(k)}}
184
- return [content, "Seats (name each one; send a verdict per seat you judged, by call id):\n"
185
- + json.dumps(data, indent=1, ensure_ascii=False)]
186
-
187
-
188
- def verdict(args: dict) -> list[str]:
189
- if not (isinstance(args.get("call"), str) and args["call"].strip()):
190
- raise ToolError("`call` is required: the seat's call id from ask")
191
- if not isinstance(args.get("useful"), bool):
192
- raise ToolError("`useful` must be true or false")
193
- body = {"call": args["call"].strip(), "useful": args["useful"], "note": args.get("note") or "",
194
- "real": args.get("real") or [], "false": args.get("false") or []}
195
- http("POST", "/bench/verdict", body)
196
- return [f"Recorded {body['call']}: {'useful' if body['useful'] else 'not useful'},"
197
- f" {len(body['real'])} real, {len(body['false'])} false."]
198
-
199
-
200
- HANDLERS = {"list_collections": list_collections, "ask": ask, "verdict": verdict}
201
- OUT = threading.Lock()
202
-
203
-
204
- def send(message: dict) -> None:
205
- line = json.dumps(message, ensure_ascii=False) + "\n"
206
- with OUT:
207
- sys.stdout.buffer.write(line.encode("utf-8"))
208
- sys.stdout.buffer.flush()
209
-
210
-
211
- def call_tool(rid, params: dict) -> None:
212
- name, args = params.get("name"), params.get("arguments") or {}
213
- if name not in HANDLERS:
214
- return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name!r}"}})
215
- try:
216
- if not isinstance(args, dict):
217
- raise ToolError("arguments must be an object")
218
- texts, failed = HANDLERS[name](args), False
219
- except ToolError as e:
220
- texts, failed = [str(e)], True
221
- except Exception as e: # noqa: BLE001 -- a bad reply must not kill the server
222
- texts, failed = [f"{type(e).__name__}: {e}"], True
223
- send({"jsonrpc": "2.0", "id": rid,
224
- "result": {"content": [{"type": "text", "text": t} for t in texts], "isError": failed}})
225
-
226
-
227
- def handle(msg) -> None:
228
- if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
229
- rid = msg.get("id") if isinstance(msg, dict) else None
230
- return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}})
231
- if "id" not in msg: # a notification (initialized, cancelled): nothing to answer
232
- return
233
- rid, method, params = msg["id"], msg["method"], msg.get("params") or {}
234
- if method == "initialize":
235
- asked = params.get("protocolVersion")
236
- send({"jsonrpc": "2.0", "id": rid, "result": {
237
- "protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0],
238
- "capabilities": {"tools": {"listChanged": False}},
239
- "serverInfo": {"name": "benchwarmers", "title": "Benchwarmers", "version": VERSION},
240
- "instructions": RULES}})
241
- elif method == "ping":
242
- send({"jsonrpc": "2.0", "id": rid, "result": {}})
243
- elif method == "tools/list":
244
- send({"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}})
245
- elif method == "tools/call":
246
- # A panel takes minutes: on its own thread, so a ping or a second call is answered meanwhile.
247
- # Not a daemon: when stdin closes, a call already sent (a verdict) still finishes.
248
- threading.Thread(target=call_tool, args=(rid, params)).start()
249
- else:
250
- send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no method {method!r}"}})
251
-
252
-
253
- def main() -> None:
254
- for raw in sys.stdin.buffer:
255
- if not raw.strip():
256
- continue
257
- try:
258
- msg = json.loads(raw)
259
- except ValueError:
260
- send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
261
- continue
262
- handle(msg)
263
-
264
-
265
- if __name__ == "__main__":
266
- main()
1
+ """Benchwarmers as an MCP server, so an agent (Claude Code, or any MCP client)
2
+ can ask a collection and send its verdicts.
3
+
4
+ benchwarmers mcp # from npm: npx -y @wendellresearch-ai/benchwarmers mcp
5
+ python mcp_server.py # from a checkout
6
+
7
+ MCP over stdio: JSON-RPC 2.0, one message per line, nothing else on stdout.
8
+ A THIN CLIENT of a Benchwarmers server that is already running, over the same
9
+ HTTP routes any other caller uses (GATEWAY.md), so the panel, the call log and
10
+ the verdicts are that server's, whichever machine it is on:
11
+
12
+ BENCHWARMERS_URL the server, default http://127.0.0.1:8122 (a /v1 on the end is fine)
13
+ BENCHWARMERS_KEY a gateway key, sent as `Authorization: Bearer`; needed beyond
14
+ this machine, or where BENCHWARMERS_REQUIRE_KEYS is set (metrics/guard.py)
15
+ BENCHWARMERS_TIMEOUT seconds to wait for a panel, default 900
16
+
17
+ Tools: list_collections (GET /v1/models), ask (POST /v1/chat/completions),
18
+ verdict (POST /bench/verdict). A refusal from the server, or no server at
19
+ all, comes back as a tool error carrying its message. Standard library only,
20
+ and nothing from metrics/: the data folder is the server's, not this one's.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import sys
28
+ import threading
29
+ import urllib.error
30
+ import urllib.request
31
+ from pathlib import Path
32
+
33
+ # Newest first: the client's version when it is one of these, else the newest.
34
+ PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
35
+ PREFIX = "bench/"
36
+ VERSION = (Path(__file__).resolve().parent / "VERSION").read_text(encoding="utf-8").strip()
37
+
38
+ RULES = (
39
+ "Benchwarmers asks a COLLECTION of models at once (every member plus explorer seats it rotates in)"
40
+ " and keeps every answer. Rules: (1) ask a collection, not a favourite model; list_collections says"
41
+ " what each is for. (2) Verify before acting on anything a seat says: seats fail together, so"
42
+ " agreement is not evidence. (3) Send a verdict per seat you judged, with the claims it got right"
43
+ " in `real` and the untrue ones in `false`. (4) Name every seat that answered, by model and seat,"
44
+ " including errors and empty replies. An empty reply from a reasoning model is usually its token"
45
+ " budget, not incapacity. Good uses: exhaustive enumeration over real files, adversarial review."
46
+ " Keep the design and the decisions yourself.")
47
+
48
+ TOOLS = [
49
+ {"name": "list_collections",
50
+ "title": "List collections",
51
+ "description": "The collections this Benchwarmers server offers: name, what each is for, tags, members,"
52
+ " explorer seats, reply mode, and what the members handle together (smallest context"
53
+ " window, shared capabilities, tightest rate limits). Pick by task before calling ask.",
54
+ "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
55
+ "annotations": {"readOnlyHint": True, "openWorldHint": False}},
56
+ {"name": "ask",
57
+ "title": "Ask a collection",
58
+ "description": "Put a prompt to every seat of one collection at once and get every answer back, each"
59
+ " under its seat and model, plus the seats as data (alias, seat, status, call id). Can"
60
+ " take minutes. Ask a collection, not a favourite model. Give the seats the real text"
61
+ " (code, diff, file contents) in the prompt: they cannot read your files. Verify every"
62
+ " claim against the source before acting on it, name every seat that answered in what"
63
+ " you tell the user, then send a verdict per seat you judged.",
64
+ "inputSchema": {"type": "object", "required": ["collection", "prompt"], "additionalProperties": False,
65
+ "properties": {
66
+ "collection": {"type": "string",
67
+ "description": "A collection name, `core` or `bench/core` (list_collections)."},
68
+ "prompt": {"type": "string", "description": "The question, with everything it needs."},
69
+ "system": {"type": "string", "description": "A system message for every seat."},
70
+ "merged": {"type": "boolean",
71
+ "description": "true: one reconciled answer, every seat still called and"
72
+ " listed; false: every answer. Omit for the collection's own."},
73
+ "with": {"type": "array", "items": {"type": "string"},
74
+ "description": "Model aliases to seat in explorer chairs, this request only."}}},
75
+ "annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True}},
76
+ {"name": "verdict",
77
+ "title": "Record a verdict",
78
+ "description": "Judge one seat's answer by its call id, once you have checked it against the source. One"
79
+ " per seat you judged. List the claims it got right in `real` and the claims that are"
80
+ " untrue in `false`, one sentence each: they feed routing and the 'confidently wrong'"
81
+ " record, so a note alone is not enough.",
82
+ "inputSchema": {"type": "object", "required": ["call", "useful"], "additionalProperties": False,
83
+ "properties": {
84
+ "call": {"type": "string", "description": "The seat's call id, e.g. p20261022-101500-ab12.member1."},
85
+ "useful": {"type": "boolean"},
86
+ "note": {"type": "string", "description": "One or two sentences on why."},
87
+ "real": {"type": "array", "items": {"type": "string"},
88
+ "description": "Claims it made that are true, one sentence each."},
89
+ "false": {"type": "array", "items": {"type": "string"},
90
+ "description": "Claims it made that are untrue, one sentence each."}}},
91
+ "annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True,
92
+ "openWorldHint": False}},
93
+ ]
94
+
95
+
96
+ class ToolError(Exception):
97
+ """A tool call that failed: its message goes back as the tool's result, isError."""
98
+
99
+
100
+ def base_url() -> str:
101
+ url = (os.environ.get("BENCHWARMERS_URL") or "").strip().rstrip("/") or "http://127.0.0.1:8122"
102
+ return url.removesuffix("/v1")
103
+
104
+
105
+ def http(method: str, path: str, body: dict | None = None) -> dict:
106
+ url = base_url() + path
107
+ headers = {"Accept": "application/json"}
108
+ if key := (os.environ.get("BENCHWARMERS_KEY") or "").strip():
109
+ headers["Authorization"] = f"Bearer {key}"
110
+ data = None
111
+ if body is not None:
112
+ data = json.dumps(body).encode()
113
+ headers["Content-Type"] = "application/json"
114
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
115
+ timeout = float(os.environ.get("BENCHWARMERS_TIMEOUT") or 900)
116
+ try:
117
+ with urllib.request.urlopen(req, timeout=timeout) as r:
118
+ return json.load(r)
119
+ except urllib.error.HTTPError as e:
120
+ raw = e.read().decode("utf-8", "replace")
121
+ try:
122
+ message = json.loads(raw)["error"]["message"]
123
+ except (ValueError, KeyError, TypeError):
124
+ message = raw.strip()[:500] or e.reason
125
+ raise ToolError(f"Benchwarmers refused {method} {path} ({e.code}): {message}") from None
126
+ except (urllib.error.URLError, ConnectionError) as e:
127
+ reason = getattr(e, "reason", e)
128
+ if isinstance(reason, TimeoutError):
129
+ raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
130
+ " set BENCHWARMERS_TIMEOUT for longer panels") from None
131
+ raise ToolError(f"Benchwarmers is not running at {base_url()} ({reason}). Start it (`benchwarmers`,"
132
+ " or its service), or point BENCHWARMERS_URL at the server you use.") from None
133
+ except TimeoutError:
134
+ raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
135
+ " set BENCHWARMERS_TIMEOUT for longer panels") from None
136
+ except ValueError:
137
+ raise ToolError(f"{base_url()} answered {method} {path} with something that is not JSON;"
138
+ " is BENCHWARMERS_URL a Benchwarmers server?") from None
139
+
140
+
141
+ def list_collections(_args: dict) -> list[str]:
142
+ rows = []
143
+ for m in http("GET", "/v1/models").get("data") or []:
144
+ if ":" in m.get("id", ""): # the same collection in its other reply mode
145
+ continue
146
+ together = m.get("together") or {}
147
+ rows.append({"name": m["id"], **{k: m.get(k) for k in ("description", "tags", "members", "explorers",
148
+ "shape", "reply", "merger")},
149
+ **{k: together.get(k) for k in ("context_window", "capabilities", "limits")}})
150
+ if not rows:
151
+ return ["No collections on this server (or none this gateway key may call). Make one on the"
152
+ " dashboard's Collections page."]
153
+ return [json.dumps(rows, indent=1, ensure_ascii=False)]
154
+
155
+
156
+ def ask(args: dict) -> list[str]:
157
+ collection, prompt = args.get("collection"), args.get("prompt")
158
+ if not (isinstance(collection, str) and collection.strip()):
159
+ raise ToolError("`collection` is required: a name from list_collections, e.g. core")
160
+ if not (isinstance(prompt, str) and prompt.strip()):
161
+ raise ToolError("`prompt` is required")
162
+ collection = collection.strip()
163
+ messages = [{"role": "user", "content": prompt}]
164
+ if isinstance(args.get("system"), str) and args["system"].strip():
165
+ messages.insert(0, {"role": "system", "content": args["system"]})
166
+ body = {"model": collection if collection.startswith(PREFIX) else PREFIX + collection, "messages": messages}
167
+ if isinstance(args.get("merged"), bool):
168
+ body["bench"] = {"reply": "merged" if args["merged"] else "all"}
169
+ if args.get("with"):
170
+ body["with"] = args["with"]
171
+ reply = http("POST", "/v1/chat/completions", body)
172
+ content = reply["choices"][0]["message"]["content"]
173
+ bench = reply.get("bench") or {}
174
+ merged = bench.get("reply") == "merged"
175
+ # Each answer is already in the content unless it is the merge, so the
176
+ # seat data stays short: who sat, how it went, the id to judge it by.
177
+ keep = ("call", "seat", "alias", "status", "seconds", "error") + (("answer",) if merged else ())
178
+ seats = [{k: s[k] for k in keep if s.get(k) not in (None, "")} for s in bench.get("seats") or []]
179
+ for s in seats:
180
+ if "error" in s:
181
+ s["error"] = s["error"][-300:] # a traceback's last lines say what went wrong
182
+ data = {"panel": bench.get("panel"), "collection": bench.get("collection"), "shape": bench.get("shape"),
183
+ "seats": seats, **{k: bench[k] for k in ("merger", "skipped", "note") if bench.get(k)}}
184
+ return [content, "Seats (name each one; send a verdict per seat you judged, by call id):\n"
185
+ + json.dumps(data, indent=1, ensure_ascii=False)]
186
+
187
+
188
+ def verdict(args: dict) -> list[str]:
189
+ if not (isinstance(args.get("call"), str) and args["call"].strip()):
190
+ raise ToolError("`call` is required: the seat's call id from ask")
191
+ if not isinstance(args.get("useful"), bool):
192
+ raise ToolError("`useful` must be true or false")
193
+ body = {"call": args["call"].strip(), "useful": args["useful"], "note": args.get("note") or "",
194
+ "real": args.get("real") or [], "false": args.get("false") or []}
195
+ http("POST", "/bench/verdict", body)
196
+ return [f"Recorded {body['call']}: {'useful' if body['useful'] else 'not useful'},"
197
+ f" {len(body['real'])} real, {len(body['false'])} false."]
198
+
199
+
200
+ HANDLERS = {"list_collections": list_collections, "ask": ask, "verdict": verdict}
201
+ OUT = threading.Lock()
202
+
203
+
204
+ def send(message: dict) -> None:
205
+ line = json.dumps(message, ensure_ascii=False) + "\n"
206
+ with OUT:
207
+ sys.stdout.buffer.write(line.encode("utf-8"))
208
+ sys.stdout.buffer.flush()
209
+
210
+
211
+ def call_tool(rid, params: dict) -> None:
212
+ name, args = params.get("name"), params.get("arguments") or {}
213
+ if name not in HANDLERS:
214
+ return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name!r}"}})
215
+ try:
216
+ if not isinstance(args, dict):
217
+ raise ToolError("arguments must be an object")
218
+ texts, failed = HANDLERS[name](args), False
219
+ except ToolError as e:
220
+ texts, failed = [str(e)], True
221
+ except Exception as e: # noqa: BLE001 -- a bad reply must not kill the server
222
+ texts, failed = [f"{type(e).__name__}: {e}"], True
223
+ send({"jsonrpc": "2.0", "id": rid,
224
+ "result": {"content": [{"type": "text", "text": t} for t in texts], "isError": failed}})
225
+
226
+
227
+ def handle(msg) -> None:
228
+ if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
229
+ rid = msg.get("id") if isinstance(msg, dict) else None
230
+ return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}})
231
+ if "id" not in msg: # a notification (initialized, cancelled): nothing to answer
232
+ return
233
+ rid, method, params = msg["id"], msg["method"], msg.get("params") or {}
234
+ if method == "initialize":
235
+ asked = params.get("protocolVersion")
236
+ send({"jsonrpc": "2.0", "id": rid, "result": {
237
+ "protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0],
238
+ "capabilities": {"tools": {"listChanged": False}},
239
+ "serverInfo": {"name": "benchwarmers", "title": "Benchwarmers", "version": VERSION},
240
+ "instructions": RULES}})
241
+ elif method == "ping":
242
+ send({"jsonrpc": "2.0", "id": rid, "result": {}})
243
+ elif method == "tools/list":
244
+ send({"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}})
245
+ elif method == "tools/call":
246
+ # A panel takes minutes: on its own thread, so a ping or a second call is answered meanwhile.
247
+ # Not a daemon: when stdin closes, a call already sent (a verdict) still finishes.
248
+ threading.Thread(target=call_tool, args=(rid, params)).start()
249
+ else:
250
+ send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no method {method!r}"}})
251
+
252
+
253
+ def main() -> None:
254
+ for raw in sys.stdin.buffer:
255
+ if not raw.strip():
256
+ continue
257
+ try:
258
+ msg = json.loads(raw)
259
+ except ValueError:
260
+ send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
261
+ continue
262
+ handle(msg)
263
+
264
+
265
+ if __name__ == "__main__":
266
+ main()