@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
@@ -0,0 +1,67 @@
1
+ """launcher.py: what each command line runs, and that it really runs.
2
+
3
+ python scripts/test_launcher.py
4
+
5
+ The launcher is what `benchwarmers` and `bench` are on every install (pip's
6
+ entry point, npm's bin/benchwarmers.js hands over to it, a checkout runs it
7
+ directly), so each route is checked: the version, the help, an unknown
8
+ command, the servers (with the first run where it belongs) and the client
9
+ (in the caller's folder, its own --help). Then two real runs: --version, and
10
+ mcp answering initialize with nothing else on stdout.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import pathlib
18
+ import subprocess
19
+ import sys
20
+
21
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
22
+ sys.path.insert(0, str(ROOT))
23
+ import launcher # noqa: E402
24
+
25
+
26
+ def main() -> int:
27
+ v = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
28
+ assert launcher.plan(["--version"]) == {"say": v, "code": 0}
29
+ for argv in ([], ["serve"], ["--port-is-not-a-flag"]): # no command: the dashboard
30
+ p = launcher.plan(argv)
31
+ assert p["script"] == "serve.py" and p["first_run"], (argv, p)
32
+ assert launcher.plan(["serve", "--x"])["args"] == ["--x"]
33
+ for argv in (["--help"], ["serve", "-h"], ["gateway", "--help"]):
34
+ p = launcher.plan(argv)
35
+ assert p["code"] == 0 and "ask <collection>" in p["say"], argv
36
+ p = launcher.plan(["nope"])
37
+ assert p["code"] == 2 and p.get("stderr") and 'no command "nope"' in p["say"], p
38
+ p = launcher.plan(["init", "--help"]) # init's own help
39
+ assert p["script"].endswith("init.py") and p["args"] == ["--help"] and not p["first_run"], p
40
+ p = launcher.plan(["mcp"])
41
+ assert p["script"] == "mcp_server.py" and not p["first_run"], p # stdout is the protocol's
42
+ for argv in (["status"], ["ask", "core", "hi", "--help"], ["collection", "rm", "x", "--yes"]):
43
+ p = launcher.plan(argv)
44
+ assert p["script"] == "cli.py" and p["args"] == argv, (argv, p)
45
+ assert p["cwd"] == os.getcwd() and not p["first_run"], p # the caller's folder: -f is theirs
46
+ assert launcher.plan(["gateway"])["cwd"] == str(ROOT)
47
+
48
+ env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
49
+ done = subprocess.run([sys.executable, str(ROOT / "launcher.py"), "--version"], capture_output=True,
50
+ text=True, env=env, timeout=60)
51
+ assert done.returncode == 0 and done.stdout.strip() == v, done
52
+ done = subprocess.run([sys.executable, str(ROOT / "launcher.py"), "nope"], capture_output=True,
53
+ text=True, env=env, timeout=60)
54
+ assert done.returncode == 2 and not done.stdout and "nope" in done.stderr, done
55
+ hello = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize",
56
+ "params": {"protocolVersion": "2025-06-18"}}) + "\n"
57
+ done = subprocess.run([sys.executable, str(ROOT / "launcher.py"), "mcp"], input=hello, capture_output=True,
58
+ text=True, env=env, timeout=60)
59
+ lines = done.stdout.splitlines()
60
+ assert done.returncode == 0 and len(lines) == 1, done
61
+ assert json.loads(lines[0])["result"]["serverInfo"]["version"] == v, lines
62
+ print("ok")
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main())
@@ -1,81 +1,85 @@
1
- """An answer stays an answer when the `llm` CLI fails to write its own log.
2
-
3
- python scripts/test_llm_log.py
4
-
5
- 2026-10-22, panel p20261022-160401-6ca2: seat inkling-small-free printed
6
- "pong" and was recorded as an error, because `llm` then raised
7
- "sqlite3.OperationalError: database is locked" writing logs.db, which the
8
- other seats' `llm` processes held. A fake `llm` here prints an answer and
9
- fails that way, through the two places the bench runs `llm -m`: the gateway's
10
- seat call and suite/run.py's ask_once (the perf probe, the suite, repoqa,
11
- retries, auto-retire). The same lock before any answer, and a stream cut
12
- mid-answer, stay errors. BENCHWARMERS_HOME and LLM_USER_PATH name temp
13
- folders before anything is imported.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import os
19
- import pathlib
20
- import sys
21
- import tempfile
22
-
23
- ROOT = pathlib.Path(__file__).resolve().parent.parent
24
- TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-llmlog-"))
25
- (TMP / "config").mkdir()
26
- (TMP / "llm").mkdir()
27
- os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="llm")
28
- sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
29
-
30
- FAKE = '''import os, sys
31
- sys.stdin.read()
32
- mode = os.environ["FAKE_LLM_MODE"]
33
- if mode in ("locked", "cut"):
34
- sys.stdout.write("pong\\n" if mode == "locked" else "half an ans")
35
- sys.stdout.flush()
36
- if mode == "cut":
37
- sys.stderr.write("Error: Connection error.\\n")
38
- sys.exit(1)
39
- if mode != "ok":
40
- sys.stderr.write("Traceback (most recent call last):\\n"
41
- " File \\"llm/models.py\\", line 1, in log_to_db\\n"
42
- "sqlite3.OperationalError: database is locked\\n")
43
- sys.exit(1)
44
- sys.stdout.write("pong\\n")
45
- '''
46
-
47
-
48
- def fake_llm() -> str:
49
- """A path that runs like an `llm` executable on Windows, macOS and Linux."""
50
- script = TMP / "fake_llm.py"
51
- script.write_text(FAKE, encoding="utf-8")
52
- if os.name == "nt":
53
- shim = TMP / "llm.cmd"
54
- shim.write_text(f'@"{sys.executable}" "{script}" %*\r\n@exit /b %ERRORLEVEL%\r\n', encoding="utf-8")
55
- else:
56
- shim = TMP / "llm"
57
- shim.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n', encoding="utf-8")
58
- shim.chmod(0o755)
59
- return str(shim)
60
-
61
-
62
- def main() -> int:
63
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
64
- import gateway
65
- import run
66
- binary = fake_llm()
67
- for mode, answered in (("ok", True), ("locked", True), ("locked-early", False), ("cut", False)):
68
- os.environ["FAKE_LLM_MODE"] = mode
69
- answer, error = gateway._ask_llm([binary, "-m", "m1"], "ping")
70
- assert (answer, error == "") == (("pong", True) if answered else (answer, False)), (mode, answer, error)
71
- body, _ = run.ask_once(binary, "m1", "ping", 60)
72
- kind = (run.outcome(body, min_length=1) or ("answered", ""))[0]
73
- assert (kind == "answered") == answered and (body.strip() == "pong") == answered, (mode, kind, body)
74
- if mode == "locked-early":
75
- assert "database is locked" in error and "database is locked" in body, (error, body)
76
- print("ok")
77
- return 0
78
-
79
-
80
- if __name__ == "__main__":
81
- sys.exit(main())
1
+ """An answer stays an answer when the `llm` CLI fails to write its own log.
2
+
3
+ python scripts/test_llm_log.py
4
+
5
+ 2026-10-22, panel p20261022-160401-6ca2: seat inkling-small-free printed
6
+ "pong" and was recorded as an error, because `llm` then raised
7
+ "sqlite3.OperationalError: database is locked" writing logs.db, which the
8
+ other seats' `llm` processes held. A fake `llm` here prints an answer and
9
+ fails that way, through the two places the bench runs `llm -m`: the gateway's
10
+ seat call and suite/run.py's ask_once (the perf probe, the suite, repoqa,
11
+ retries, auto-retire). The same lock before any answer, and a stream cut
12
+ mid-answer, stay errors. BENCHWARMERS_HOME and LLM_USER_PATH name temp
13
+ folders before anything is imported.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import pathlib
20
+ import sys
21
+ import tempfile
22
+
23
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
24
+ TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-llmlog-"))
25
+ (TMP / "config").mkdir()
26
+ (TMP / "llm").mkdir()
27
+ os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="llm")
28
+ sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
29
+
30
+ FAKE = '''import os, sys
31
+ sys.stdin.read()
32
+ mode = os.environ["FAKE_LLM_MODE"]
33
+ if mode in ("locked", "cut"):
34
+ sys.stdout.write("pong\\n" if mode == "locked" else "half an ans")
35
+ sys.stdout.flush()
36
+ if mode == "cut":
37
+ sys.stderr.write("Error: Connection error.\\n")
38
+ sys.exit(1)
39
+ if mode != "ok":
40
+ sys.stderr.write("Traceback (most recent call last):\\n"
41
+ " File \\"llm/models.py\\", line 1, in log_to_db\\n"
42
+ "sqlite3.OperationalError: database is locked\\n")
43
+ sys.exit(1)
44
+ sys.stdout.write("pong\\n")
45
+ '''
46
+
47
+
48
+ def fake_llm() -> str:
49
+ """A path that runs like an `llm` executable on Windows, macOS and Linux."""
50
+ # Its own folder: TMP/llm is llm's user folder (LLM_USER_PATH), so a shim
51
+ # named llm beside it would be that folder on macOS and Linux.
52
+ bin_dir = TMP / "bin"
53
+ bin_dir.mkdir(exist_ok=True)
54
+ script = bin_dir / "fake_llm.py"
55
+ script.write_text(FAKE, encoding="utf-8")
56
+ if os.name == "nt":
57
+ shim = bin_dir / "llm.cmd"
58
+ shim.write_text(f'@"{sys.executable}" "{script}" %*\r\n@exit /b %ERRORLEVEL%\r\n', encoding="utf-8")
59
+ else:
60
+ shim = bin_dir / "llm"
61
+ shim.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n', encoding="utf-8")
62
+ shim.chmod(0o755)
63
+ return str(shim)
64
+
65
+
66
+ def main() -> int:
67
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
68
+ import gateway
69
+ import run
70
+ binary = fake_llm()
71
+ for mode, answered in (("ok", True), ("locked", True), ("locked-early", False), ("cut", False)):
72
+ os.environ["FAKE_LLM_MODE"] = mode
73
+ answer, error = gateway._ask_llm([binary, "-m", "m1"], "ping")
74
+ assert (answer, error == "") == (("pong", True) if answered else (answer, False)), (mode, answer, error)
75
+ body, _ = run.ask_once(binary, "m1", "ping", 60)
76
+ kind = (run.outcome(body, min_length=1) or ("answered", ""))[0]
77
+ assert (kind == "answered") == answered and (body.strip() == "pong") == answered, (mode, kind, body)
78
+ if mode == "locked-early":
79
+ assert "database is locked" in error and "database is locked" in body, (error, body)
80
+ print("ok")
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ sys.exit(main())
@@ -1,178 +1,178 @@
1
- """The MCP server (mcp_server.py) as a client starts it: a subprocess on stdio, against a stub server.
2
-
3
- python scripts/test_mcp.py
4
-
5
- initialize and its protocol version negotiation, ping, tools/list, a call of
6
- each tool (what reaches the server, gateway key included, and what comes
7
- back), a refusal passed through as a tool error, a ping answered while a
8
- panel is still running, protocol errors, and the error when no Benchwarmers
9
- is running. Nothing but protocol messages may reach stdout.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import json
15
- import os
16
- import pathlib
17
- import socket
18
- import subprocess
19
- import sys
20
- import threading
21
- import time
22
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
-
24
- ROOT = pathlib.Path(__file__).resolve().parent.parent
25
- SEEN: list[tuple[str, str, dict, str]] = [] # (method, path, body, Authorization)
26
-
27
- MODELS = {"object": "list", "data": [
28
- {"id": "bench/core", "description": "second opinions", "tags": ["general"], "members": ["m1", "m2"],
29
- "explorers": 2, "shape": "", "reply": "all", "merger": "",
30
- "together": {"context_window": 128000, "capabilities": ["tools"], "connections": ["x"], "limits": {}}},
31
- {"id": "bench/core:merged", "description": "second opinions", "reply": "merged"}]}
32
- SEATS = [{"call": "p1.member1", "seat": "member", "alias": "m1", "status": "ok", "seconds": 1.0,
33
- "answer": "answer one", "error": ""},
34
- {"call": "p1.explorer", "seat": "explorer", "alias": "x1", "status": "empty", "seconds": 2.0,
35
- "answer": "", "error": ""}]
36
-
37
-
38
- class Stub(BaseHTTPRequestHandler):
39
- def do_GET(self): # noqa: N802
40
- SEEN.append(("GET", self.path, {}, self.headers.get("Authorization", "")))
41
- self._send(200, MODELS)
42
-
43
- def do_POST(self): # noqa: N802
44
- body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
45
- SEEN.append(("POST", self.path, body, self.headers.get("Authorization", "")))
46
- if self.path == "/bench/verdict":
47
- return self._send(200, {"ok": True, "recorded": {"call": body["call"]}})
48
- if body["model"] == "bench/nope":
49
- return self._send(404, {"error": {"message": "no collection nope; choose one of bench/core"}})
50
- time.sleep(1) # a panel takes a while
51
- merged = (body.get("bench") or {}).get("reply") == "merged"
52
- self._send(200, {"choices": [{"message": {"role": "assistant", "content": "merged answer" if merged
53
- else "## member — m1\nanswer one"}}],
54
- "bench": {"panel": "p1", "collection": body["model"], "shape": "general",
55
- "seats": SEATS, "reply": "merged" if merged else "all"}})
56
-
57
- def _send(self, status, data):
58
- raw = json.dumps(data).encode()
59
- self.send_response(status)
60
- self.send_header("Content-Type", "application/json")
61
- self.send_header("Content-Length", str(len(raw)))
62
- self.end_headers()
63
- self.wfile.write(raw)
64
-
65
- def log_message(self, *_a):
66
- pass
67
-
68
-
69
- class Client:
70
- def __init__(self, url: str, key: str = ""):
71
- env = {**os.environ, "BENCHWARMERS_URL": url, "BENCHWARMERS_KEY": key, "BENCHWARMERS_TIMEOUT": "30"}
72
- self.p = subprocess.Popen([sys.executable, str(ROOT / "mcp_server.py")], stdin=subprocess.PIPE,
73
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
74
- self.next = 0
75
-
76
- def send(self, msg) -> None:
77
- self.p.stdin.write((msg if isinstance(msg, str) else json.dumps(msg)).encode() + b"\n")
78
- self.p.stdin.flush()
79
-
80
- def read(self) -> dict:
81
- line = self.p.stdout.readline()
82
- assert line, "the server closed stdout: " + self.p.stderr.read().decode()
83
- return json.loads(line) # every stdout line must be one JSON-RPC message
84
-
85
- def request(self, method: str, params: dict | None = None) -> dict:
86
- self.next += 1
87
- self.send({"jsonrpc": "2.0", "id": self.next, "method": method, **({"params": params} if params else {})})
88
- reply = self.read()
89
- assert reply["id"] == self.next and reply["jsonrpc"] == "2.0", reply
90
- return reply
91
-
92
- def tool(self, name: str, **arguments) -> dict:
93
- return self.request("tools/call", {"name": name, "arguments": arguments})["result"]
94
-
95
- def close(self) -> None:
96
- self.p.stdin.close()
97
- self.p.wait(timeout=10)
98
-
99
-
100
- def main() -> int:
101
- stub = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
102
- threading.Thread(target=stub.serve_forever, daemon=True).start()
103
- url = f"http://127.0.0.1:{stub.server_port}"
104
- c = Client(url + "/v1", key="bwk_test") # a pasted /v1 is fine
105
-
106
- init = c.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
107
- "clientInfo": {"name": "test", "version": "0"}})["result"]
108
- assert init["protocolVersion"] == "2025-06-18", init
109
- assert init["capabilities"] == {"tools": {"listChanged": False}}, init
110
- assert init["serverInfo"]["name"] == "benchwarmers" and "verdict" in init["instructions"], init
111
- c.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) # no answer to a notification
112
- assert c.request("ping")["result"] == {}
113
-
114
- tools = {t["name"]: t for t in c.request("tools/list")["result"]["tools"]}
115
- assert set(tools) == {"list_collections", "ask", "verdict"}, tools
116
- assert tools["ask"]["inputSchema"]["required"] == ["collection", "prompt"]
117
-
118
- r = c.tool("list_collections")
119
- assert not r["isError"], r
120
- rows = json.loads(r["content"][0]["text"])
121
- assert [x["name"] for x in rows] == ["bench/core"], rows # the :merged twin is folded in
122
- assert rows[0]["context_window"] == 128000 and rows[0]["members"] == ["m1", "m2"], rows
123
- assert SEEN[-1][:2] == ("GET", "/v1/models") and SEEN[-1][3] == "Bearer bwk_test", SEEN[-1]
124
-
125
- # A ping sent while the panel runs is answered first.
126
- c.send({"jsonrpc": "2.0", "id": "a", "method": "tools/call",
127
- "params": {"name": "ask", "arguments": {"collection": "core", "prompt": "hi", "system": "be brief",
128
- "with": ["x1"]}}})
129
- c.send({"jsonrpc": "2.0", "id": "p", "method": "ping"})
130
- first, second = c.read(), c.read()
131
- assert (first["id"], second["id"]) == ("p", "a"), (first, second)
132
- r = second["result"]
133
- assert not r["isError"] and "answer one" in r["content"][0]["text"], r
134
- seats = json.loads(r["content"][1]["text"].split("\n", 1)[1])["seats"]
135
- assert [s["call"] for s in seats] == ["p1.member1", "p1.explorer"] and "answer" not in seats[0], seats
136
- method, path, body, _ = SEEN[-1]
137
- assert (method, path) == ("POST", "/v1/chat/completions"), SEEN[-1]
138
- assert body["model"] == "bench/core" and body["with"] == ["x1"] and "bench" not in body, body
139
- assert body["messages"] == [{"role": "system", "content": "be brief"}, {"role": "user", "content": "hi"}]
140
-
141
- r = c.tool("ask", collection="bench/core", prompt="hi", merged=True)
142
- assert SEEN[-1][2]["bench"] == {"reply": "merged"}, SEEN[-1]
143
- assert r["content"][0]["text"] == "merged answer" and "answer one" in r["content"][1]["text"], r
144
-
145
- r = c.tool("ask", collection="nope", prompt="hi") # the server's refusal, passed on
146
- assert r["isError"] and "(404)" in r["content"][0]["text"] and "choose one" in r["content"][0]["text"], r
147
- r = c.tool("ask", collection="core")
148
- assert r["isError"] and "prompt" in r["content"][0]["text"], r
149
-
150
- r = c.tool("verdict", call="p1.member1", useful=True, note="checked", real=["a true claim"], false=["x"])
151
- assert not r["isError"] and "1 real, 1 false" in r["content"][0]["text"], r
152
- assert SEEN[-1][1:3] == ("/bench/verdict", {"call": "p1.member1", "useful": True, "note": "checked",
153
- "real": ["a true claim"], "false": ["x"]}), SEEN[-1]
154
- assert c.tool("verdict", call="p1.member1")["isError"] # useful is required
155
-
156
- assert c.request("tools/call", {"name": "nope"})["error"]["code"] == -32602
157
- assert c.request("resources/list")["error"]["code"] == -32601
158
- c.send("{not json")
159
- assert c.read()["error"]["code"] == -32700
160
- c.close()
161
-
162
- # An unknown protocol version gets the newest; no server running is a clear tool error.
163
- with socket.socket() as s:
164
- s.bind(("127.0.0.1", 0))
165
- dead = s.getsockname()[1]
166
- c = Client(f"http://127.0.0.1:{dead}")
167
- init = c.request("initialize", {"protocolVersion": "1999-01-01", "capabilities": {}})["result"]
168
- assert init["protocolVersion"] == "2025-11-25", init
169
- r = c.tool("list_collections")
170
- assert r["isError"] and "not running" in r["content"][0]["text"], r
171
- c.close()
172
- stub.shutdown()
173
- print("mcp server ok: initialize, ping, tools/list, each tool, refusals, a ping mid-panel, not running")
174
- return 0
175
-
176
-
177
- if __name__ == "__main__":
178
- sys.exit(main())
1
+ """The MCP server (mcp_server.py) as a client starts it: a subprocess on stdio, against a stub server.
2
+
3
+ python scripts/test_mcp.py
4
+
5
+ initialize and its protocol version negotiation, ping, tools/list, a call of
6
+ each tool (what reaches the server, gateway key included, and what comes
7
+ back), a refusal passed through as a tool error, a ping answered while a
8
+ panel is still running, protocol errors, and the error when no Benchwarmers
9
+ is running. Nothing but protocol messages may reach stdout.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import pathlib
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ import time
22
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
+
24
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
25
+ SEEN: list[tuple[str, str, dict, str]] = [] # (method, path, body, Authorization)
26
+
27
+ MODELS = {"object": "list", "data": [
28
+ {"id": "bench/core", "description": "second opinions", "tags": ["general"], "members": ["m1", "m2"],
29
+ "explorers": 2, "shape": "", "reply": "all", "merger": "",
30
+ "together": {"context_window": 128000, "capabilities": ["tools"], "connections": ["x"], "limits": {}}},
31
+ {"id": "bench/core:merged", "description": "second opinions", "reply": "merged"}]}
32
+ SEATS = [{"call": "p1.member1", "seat": "member", "alias": "m1", "status": "ok", "seconds": 1.0,
33
+ "answer": "answer one", "error": ""},
34
+ {"call": "p1.explorer", "seat": "explorer", "alias": "x1", "status": "empty", "seconds": 2.0,
35
+ "answer": "", "error": ""}]
36
+
37
+
38
+ class Stub(BaseHTTPRequestHandler):
39
+ def do_GET(self): # noqa: N802
40
+ SEEN.append(("GET", self.path, {}, self.headers.get("Authorization", "")))
41
+ self._send(200, MODELS)
42
+
43
+ def do_POST(self): # noqa: N802
44
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
45
+ SEEN.append(("POST", self.path, body, self.headers.get("Authorization", "")))
46
+ if self.path == "/bench/verdict":
47
+ return self._send(200, {"ok": True, "recorded": {"call": body["call"]}})
48
+ if body["model"] == "bench/nope":
49
+ return self._send(404, {"error": {"message": "no collection nope; choose one of bench/core"}})
50
+ time.sleep(1) # a panel takes a while
51
+ merged = (body.get("bench") or {}).get("reply") == "merged"
52
+ self._send(200, {"choices": [{"message": {"role": "assistant", "content": "merged answer" if merged
53
+ else "## member — m1\nanswer one"}}],
54
+ "bench": {"panel": "p1", "collection": body["model"], "shape": "general",
55
+ "seats": SEATS, "reply": "merged" if merged else "all"}})
56
+
57
+ def _send(self, status, data):
58
+ raw = json.dumps(data).encode()
59
+ self.send_response(status)
60
+ self.send_header("Content-Type", "application/json")
61
+ self.send_header("Content-Length", str(len(raw)))
62
+ self.end_headers()
63
+ self.wfile.write(raw)
64
+
65
+ def log_message(self, *_a):
66
+ pass
67
+
68
+
69
+ class Client:
70
+ def __init__(self, url: str, key: str = ""):
71
+ env = {**os.environ, "BENCHWARMERS_URL": url, "BENCHWARMERS_KEY": key, "BENCHWARMERS_TIMEOUT": "30"}
72
+ self.p = subprocess.Popen([sys.executable, str(ROOT / "mcp_server.py")], stdin=subprocess.PIPE,
73
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
74
+ self.next = 0
75
+
76
+ def send(self, msg) -> None:
77
+ self.p.stdin.write((msg if isinstance(msg, str) else json.dumps(msg)).encode() + b"\n")
78
+ self.p.stdin.flush()
79
+
80
+ def read(self) -> dict:
81
+ line = self.p.stdout.readline()
82
+ assert line, "the server closed stdout: " + self.p.stderr.read().decode()
83
+ return json.loads(line) # every stdout line must be one JSON-RPC message
84
+
85
+ def request(self, method: str, params: dict | None = None) -> dict:
86
+ self.next += 1
87
+ self.send({"jsonrpc": "2.0", "id": self.next, "method": method, **({"params": params} if params else {})})
88
+ reply = self.read()
89
+ assert reply["id"] == self.next and reply["jsonrpc"] == "2.0", reply
90
+ return reply
91
+
92
+ def tool(self, name: str, **arguments) -> dict:
93
+ return self.request("tools/call", {"name": name, "arguments": arguments})["result"]
94
+
95
+ def close(self) -> None:
96
+ self.p.stdin.close()
97
+ self.p.wait(timeout=10)
98
+
99
+
100
+ def main() -> int:
101
+ stub = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
102
+ threading.Thread(target=stub.serve_forever, daemon=True).start()
103
+ url = f"http://127.0.0.1:{stub.server_port}"
104
+ c = Client(url + "/v1", key="bwk_test") # a pasted /v1 is fine
105
+
106
+ init = c.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
107
+ "clientInfo": {"name": "test", "version": "0"}})["result"]
108
+ assert init["protocolVersion"] == "2025-06-18", init
109
+ assert init["capabilities"] == {"tools": {"listChanged": False}}, init
110
+ assert init["serverInfo"]["name"] == "benchwarmers" and "verdict" in init["instructions"], init
111
+ c.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) # no answer to a notification
112
+ assert c.request("ping")["result"] == {}
113
+
114
+ tools = {t["name"]: t for t in c.request("tools/list")["result"]["tools"]}
115
+ assert set(tools) == {"list_collections", "ask", "verdict"}, tools
116
+ assert tools["ask"]["inputSchema"]["required"] == ["collection", "prompt"]
117
+
118
+ r = c.tool("list_collections")
119
+ assert not r["isError"], r
120
+ rows = json.loads(r["content"][0]["text"])
121
+ assert [x["name"] for x in rows] == ["bench/core"], rows # the :merged twin is folded in
122
+ assert rows[0]["context_window"] == 128000 and rows[0]["members"] == ["m1", "m2"], rows
123
+ assert SEEN[-1][:2] == ("GET", "/v1/models") and SEEN[-1][3] == "Bearer bwk_test", SEEN[-1]
124
+
125
+ # A ping sent while the panel runs is answered first.
126
+ c.send({"jsonrpc": "2.0", "id": "a", "method": "tools/call",
127
+ "params": {"name": "ask", "arguments": {"collection": "core", "prompt": "hi", "system": "be brief",
128
+ "with": ["x1"]}}})
129
+ c.send({"jsonrpc": "2.0", "id": "p", "method": "ping"})
130
+ first, second = c.read(), c.read()
131
+ assert (first["id"], second["id"]) == ("p", "a"), (first, second)
132
+ r = second["result"]
133
+ assert not r["isError"] and "answer one" in r["content"][0]["text"], r
134
+ seats = json.loads(r["content"][1]["text"].split("\n", 1)[1])["seats"]
135
+ assert [s["call"] for s in seats] == ["p1.member1", "p1.explorer"] and "answer" not in seats[0], seats
136
+ method, path, body, _ = SEEN[-1]
137
+ assert (method, path) == ("POST", "/v1/chat/completions"), SEEN[-1]
138
+ assert body["model"] == "bench/core" and body["with"] == ["x1"] and "bench" not in body, body
139
+ assert body["messages"] == [{"role": "system", "content": "be brief"}, {"role": "user", "content": "hi"}]
140
+
141
+ r = c.tool("ask", collection="bench/core", prompt="hi", merged=True)
142
+ assert SEEN[-1][2]["bench"] == {"reply": "merged"}, SEEN[-1]
143
+ assert r["content"][0]["text"] == "merged answer" and "answer one" in r["content"][1]["text"], r
144
+
145
+ r = c.tool("ask", collection="nope", prompt="hi") # the server's refusal, passed on
146
+ assert r["isError"] and "(404)" in r["content"][0]["text"] and "choose one" in r["content"][0]["text"], r
147
+ r = c.tool("ask", collection="core")
148
+ assert r["isError"] and "prompt" in r["content"][0]["text"], r
149
+
150
+ r = c.tool("verdict", call="p1.member1", useful=True, note="checked", real=["a true claim"], false=["x"])
151
+ assert not r["isError"] and "1 real, 1 false" in r["content"][0]["text"], r
152
+ assert SEEN[-1][1:3] == ("/bench/verdict", {"call": "p1.member1", "useful": True, "note": "checked",
153
+ "real": ["a true claim"], "false": ["x"]}), SEEN[-1]
154
+ assert c.tool("verdict", call="p1.member1")["isError"] # useful is required
155
+
156
+ assert c.request("tools/call", {"name": "nope"})["error"]["code"] == -32602
157
+ assert c.request("resources/list")["error"]["code"] == -32601
158
+ c.send("{not json")
159
+ assert c.read()["error"]["code"] == -32700
160
+ c.close()
161
+
162
+ # An unknown protocol version gets the newest; no server running is a clear tool error.
163
+ with socket.socket() as s:
164
+ s.bind(("127.0.0.1", 0))
165
+ dead = s.getsockname()[1]
166
+ c = Client(f"http://127.0.0.1:{dead}")
167
+ init = c.request("initialize", {"protocolVersion": "1999-01-01", "capabilities": {}})["result"]
168
+ assert init["protocolVersion"] == "2025-11-25", init
169
+ r = c.tool("list_collections")
170
+ assert r["isError"] and "not running" in r["content"][0]["text"], r
171
+ c.close()
172
+ stub.shutdown()
173
+ print("mcp server ok: initialize, ping, tools/list, each tool, refusals, a ping mid-panel, not running")
174
+ return 0
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())