@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
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())
@@ -0,0 +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())
@@ -181,6 +181,27 @@ def main() -> int:
181
181
  assert serve.save_collection("chat", ["m1"], "", "", [], "merged", "mg")["ok"]
182
182
  saved = json.loads((TMP / "config" / "collections.json").read_text(encoding="utf-8"))["chat"]
183
183
  assert (saved["reply"], saved["merger"]) == ("merged", "mg"), saved
184
+
185
+ # 9. A retired model a collection still names (member, `with`, merger) is
186
+ # not called: skipped with why, and the merge goes to a merger chosen by verdicts.
187
+ write("retired.json", {"gone": {"served": "gone", "at": "2026-10-20T05:00:00+02:00", "why": "withdrawn"}})
188
+ collection(members=["m1", "gone"], merger="gone")
189
+ ASKED.clear()
190
+ r, b, content = ask("bench/core:merged", **{"with": ["gone"]})
191
+ assert [s["alias"] for s in b["seats"]] == ["m1"] and not any(q["model"] == "gone" for q in ASKED), b
192
+ assert {(k["seat"], k["alias"]) for k in b["skipped"]} == {("member", "gone"), ("merger", "gone")}, b
193
+ assert all("retired from the bench on 2026-10-20" in k["why"] for k in b["skipped"]), b["skipped"]
194
+ assert b["reply"] == "merged" and b["merger"]["alias"] != "gone", b
195
+ # Asked for every answer, the reply says who was not asked; `with` a retired model is skipped too.
196
+ collection(members=["m1"])
197
+ r, b, content = ask("bench/core", **{"with": ["gone"]})
198
+ assert b["skipped"] == [{"seat": "with", "alias": "gone", "why": b["skipped"][0]["why"]}], b
199
+ assert "Not asked: gone (retired from the bench" in content and not any(q["model"] == "gone" for q in ASKED)
200
+ # Skipped before the max_calls cap (2 here), so the member after it still sits.
201
+ collection(members=["gone", "m1", "m2"])
202
+ r, b, content = ask("bench/core")
203
+ assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and [k["alias"] for k in b["skipped"]] == ["gone"], b
204
+ (TMP / "config" / "retired.json").unlink()
184
205
  finally:
185
206
  upstream.shutdown()
186
207
  gw.shutdown()
@@ -59,7 +59,12 @@ def main() -> int:
59
59
  shutil.copy(DATA / "config" / "endpoints.json", cfg / "endpoints.json")
60
60
  serve.endpoints.FILE = cfg / "endpoints.json"
61
61
  serve.endpoints.YAML = serve.YAML_PATH
62
+ # Collections: removing a model takes it out of them, and Restore puts it back.
63
+ serve.bench_collections.FILE = cfg / "collections.json"
64
+ serve.bench_collections.SETTINGS_FILE = cfg / "gateway.json"
62
65
  real = {p: p.read_bytes() for p in (DATA / "metrics" / "alerts.json", DATA / "config" / "endpoints.json")}
66
+ real_collections = (DATA / "config" / "collections.json").read_bytes() \
67
+ if (DATA / "config" / "collections.json").exists() else None
63
68
 
64
69
  d = serve.model_directory()
65
70
  some = sorted(d)[0]
@@ -130,6 +135,13 @@ def main() -> int:
130
135
  r = serve.remove_model("no-such" if "unknown" in label else victim, **kw)
131
136
  ok = not r["ok"] and not r["changed"]
132
137
  print(("ok " if ok else "FAIL ") + label); failures += 0 if ok else 1
138
+ # In three collections: a member of two, the merger of one; a fourth without it.
139
+ colls = {"with-it": {"members": [other, victim], "merger": victim, "reply": "merged"},
140
+ "member": {"members": [victim, other], "merger": ""},
141
+ "doomed": {"members": [victim], "merger": ""},
142
+ "without": {"members": [other], "merger": other}}
143
+ (cfg / "collections.json").write_text(json.dumps(colls), encoding="utf-8")
144
+ logged = len(serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines())
133
145
  before = len(serve.registered())
134
146
  r = serve.remove_model(victim, victim, "retired by provider on 2026-09-15")
135
147
  d3 = serve.model_directory()
@@ -142,8 +154,30 @@ def main() -> int:
142
154
  and served not in serve.registered())
143
155
  print(("ok " if ok else "FAIL ") + f"removed {victim}; registry {before} -> {len(serve.registered())}")
144
156
  failures += 0 if ok else 1
157
+ after = json.loads((cfg / "collections.json").read_text(encoding="utf-8"))
158
+ log = [json.loads(x) for x in serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines()[logged:]]
159
+ ok = (after == {"with-it": {"members": [other], "merger": "", "reply": "merged"},
160
+ "member": {"members": [other], "merger": ""},
161
+ "doomed": {"members": [], "merger": ""},
162
+ "without": colls["without"]}
163
+ and retired[victim]["collections"] == {"with-it": ["members", "merger"], "member": ["members"],
164
+ "doomed": ["members"]}
165
+ and "config/collections.json" in r["changed"]
166
+ and sorted(e["field"] for e in log if e["field"].startswith("collection "))
167
+ == ["collection doomed", "collection member", "collection with-it"])
168
+ print(("ok " if ok else "FAIL ") + "removed from its collections, recorded and logged"
169
+ + ("" if ok else f" -> {after} {retired[victim]} {r['changed']} {log}")); failures += 0 if ok else 1
145
170
  expect_refused("retired name never reused", alias=other, new_alias=victim, new_role=None)
146
- # restore: same alias back, out of retired.json, role and ad hoc kept
171
+ # The collection dialog's member search matches the served name too, so the view carries it.
172
+ view = serve.collections_view()
173
+ ok = (set(view["served"]) == set(view["models"]) and victim not in view["served"]
174
+ and view["served"].get(other) == d3[other]["served"])
175
+ print(("ok " if ok else "FAIL ") + "collection view: served name for every member choice"); failures += 0 if ok else 1
176
+ # restore: same alias back, out of retired.json, role and ad hoc kept, and
177
+ # back into the collections that still exist (one deleted, one changed since)
178
+ serve.delete_collection("doomed")
179
+ serve.bench_collections.FILE.write_text(json.dumps({**json.loads(serve.bench_collections.FILE.read_text(
180
+ encoding="utf-8")), "member": {"members": [other, "someone-new"], "merger": ""}}), encoding="utf-8")
147
181
  r = serve.restore_model(victim)
148
182
  retired = json.loads((cfg / "retired.json").read_text(encoding="utf-8"))
149
183
  roles = json.loads((cfg / "model-roles.json").read_text(encoding="utf-8"))
@@ -151,6 +185,22 @@ def main() -> int:
151
185
  and roles["models"].get(victim) == new_role and victim in roles["ad_hoc"])
152
186
  print(("ok " if ok else "FAIL ") + f"restored {victim}" + ("" if ok else f" -> {r}"))
153
187
  failures += 0 if ok else 1
188
+ back = json.loads((cfg / "collections.json").read_text(encoding="utf-8"))
189
+ ok = (back == {"with-it": {"members": [other, victim], "merger": victim, "reply": "merged"},
190
+ "member": {"members": [other, "someone-new", victim], "merger": ""},
191
+ "without": colls["without"]}
192
+ and "config/collections.json" in r["changed"])
193
+ print(("ok " if ok else "FAIL ") + "restored into the collections that still exist"
194
+ + ("" if ok else f" -> {back} {r['changed']}")); failures += 0 if ok else 1
195
+ # One that was in no collection: collections.json untouched and not reported.
196
+ (cfg / "collections.json").write_text(json.dumps({"without": colls["without"]}), encoding="utf-8")
197
+ r = serve.remove_model(victim, victim, "retired for the no-collection check")
198
+ ok = (r["ok"] and "config/collections.json" not in r["changed"]
199
+ and "collections" not in json.loads((cfg / "retired.json").read_text(encoding="utf-8"))[victim])
200
+ r2 = serve.restore_model(victim)
201
+ ok = ok and r2["ok"] and "config/collections.json" not in r2["changed"]
202
+ print(("ok " if ok else "FAIL ") + "in no collection: collections.json not touched"
203
+ + ("" if ok else f" -> {r} {r2}")); failures += 0 if ok else 1
154
204
  ok = not serve.restore_model(victim)["ok"]
155
205
  print(("ok " if ok else "FAIL ") + "restore refused when not retired"); failures += 0 if ok else 1
156
206
  serve.remove_model(victim, victim, "retired again for the onboarding checks")
@@ -374,8 +424,10 @@ def main() -> int:
374
424
  real_alerts = DATA / "metrics" / "alerts.json"
375
425
  victim_keys = {k for k in json.loads(real[real_alerts] or b"{}") if k.endswith(f":{served}")}
376
426
  ok = (real[DATA / "config" / "endpoints.json"] == (DATA / "config" / "endpoints.json").read_bytes()
377
- and victim_keys <= set(json.loads(real_alerts.read_bytes() or b"{}")))
378
- print(("ok " if ok else "FAIL ") + "the real alerts and endpoints were not touched")
427
+ and victim_keys <= set(json.loads(real_alerts.read_bytes() or b"{}"))
428
+ and real_collections == ((DATA / "config" / "collections.json").read_bytes()
429
+ if (DATA / "config" / "collections.json").exists() else None))
430
+ print(("ok " if ok else "FAIL ") + "the real alerts, endpoints and collections were not touched")
379
431
  failures += 0 if ok else 1
380
432
  shutil.rmtree(tmp, ignore_errors=True)
381
433
  print("\nall rules hold" if not failures else f"\n{failures} FAILED")