@wendellresearch-ai/benchwarmers 0.1.1 → 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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -373
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/launcher.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The `benchwarmers` and `bench` commands, however Benchwarmers was installed.
|
|
2
|
+
|
|
3
|
+
pipx install benchwarmers # PyPI: this file is the entry point (pyproject.toml)
|
|
4
|
+
npm install -g @wendellresearch-ai/benchwarmers # npm: bin/benchwarmers.js finds Python and runs this
|
|
5
|
+
python launcher.py [command] # a checkout
|
|
6
|
+
|
|
7
|
+
It keeps the data in the user's profile (scripts/init.py's default_folder, or
|
|
8
|
+
BENCHWARMERS_HOME), sets that folder up on the first run, then runs the
|
|
9
|
+
command: a server (serve, gateway, mcp, init) or, for everything else, the
|
|
10
|
+
client (cli.py) in the caller's own folder. One launcher for every install, so
|
|
11
|
+
the commands, the help and the first run are written once.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ROOT = Path(__file__).resolve().parent # the app: a checkout, the npm package or the wheel's folder
|
|
22
|
+
SERVERS = {"serve": "serve.py", "gateway": "gateway.py", "mcp": "mcp_server.py",
|
|
23
|
+
"init": str(Path("scripts") / "init.py")}
|
|
24
|
+
# Everything but running a server is cli.py, a client of a running server.
|
|
25
|
+
CLIENT = ("status", "ask", "verdict", "models", "model", "candidates", "onboard", "dismiss",
|
|
26
|
+
"collections", "collection", "endpoints", "endpoint", "keys", "key", "settings")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def version() -> str:
|
|
30
|
+
return (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_home() -> Path:
|
|
34
|
+
sys.path.insert(0, str(ROOT / "scripts"))
|
|
35
|
+
try:
|
|
36
|
+
import init
|
|
37
|
+
return init.default_folder()
|
|
38
|
+
finally:
|
|
39
|
+
sys.path.pop(0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def prog() -> str:
|
|
43
|
+
"""`bench` or `benchwarmers`, as typed (npm passes it, being the one that saw it)."""
|
|
44
|
+
named = os.environ.get("BENCHWARMERS_PROG") or Path(sys.argv[0]).stem
|
|
45
|
+
return named if named in ("bench", "benchwarmers") else "benchwarmers"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def usage() -> str:
|
|
49
|
+
return "\n".join([
|
|
50
|
+
f"Usage: {prog()} [command]",
|
|
51
|
+
"",
|
|
52
|
+
"Running it:",
|
|
53
|
+
" serve the dashboard, with the gateway at /v1: http://localhost:8122 (the default)",
|
|
54
|
+
" gateway the gateway alone: http://127.0.0.1:8123/v1",
|
|
55
|
+
" init [...] set up a data folder, optionally with a first endpoint (init --help)",
|
|
56
|
+
" mcp an MCP server on stdio for agents",
|
|
57
|
+
"",
|
|
58
|
+
"Using it (a client of a running server, BENCHWARMERS_URL; --help on any):",
|
|
59
|
+
" status what is running, and where",
|
|
60
|
+
" ask <collection> [prompt] ask a collection; -f FILE adds a file, a pipe reads stdin",
|
|
61
|
+
" verdict <call> --useful|--not-useful [--real ...] [--false ...]",
|
|
62
|
+
" models | model <action> list, rename, role, retire, restore, max-tokens, ...",
|
|
63
|
+
" candidates | onboard | dismiss what could be onboarded, and onboarding it",
|
|
64
|
+
" collections | collection <action> list, show, save, rm",
|
|
65
|
+
" endpoints | endpoint <action> list, add, test, key, rm, default",
|
|
66
|
+
" keys | key <action> gateway keys: list, create, revoke",
|
|
67
|
+
" settings <gateway|client|scheduler>",
|
|
68
|
+
" --version the version",
|
|
69
|
+
"",
|
|
70
|
+
f"Your data lives in BENCHWARMERS_HOME, else {default_home()}.",
|
|
71
|
+
"BENCH_PORT changes the port. Docs: https://github.com/WendellResearch-AI/benchwarmers",
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def plan(argv: list[str]) -> dict:
|
|
76
|
+
"""What a command line means: {"say", "code"} to print and stop, or
|
|
77
|
+
{"script", "args", "cwd", "first_run"} to run. Pure, so it can be tested."""
|
|
78
|
+
first = argv[0] if argv else ""
|
|
79
|
+
command = first if first and not first.startswith("-") else "serve"
|
|
80
|
+
client = command in CLIENT
|
|
81
|
+
if first in ("--version", "-v"):
|
|
82
|
+
return {"say": version(), "code": 0}
|
|
83
|
+
# A client command's own --help is cli.py's, which knows its options; init has its own.
|
|
84
|
+
if command != "init" and not client and ("--help" in argv or "-h" in argv):
|
|
85
|
+
return {"say": usage(), "code": 0}
|
|
86
|
+
if not client and command not in SERVERS:
|
|
87
|
+
return {"say": f'{prog()}: no command "{command}"\n\n{usage()}', "code": 2, "stderr": True}
|
|
88
|
+
if client: # cli.py takes the command itself
|
|
89
|
+
return {"script": "cli.py", "args": argv, "cwd": os.getcwd(), "first_run": False}
|
|
90
|
+
# The first run makes the data folder; not for mcp, whose stdout carries protocol messages only.
|
|
91
|
+
return {"script": SERVERS[command], "args": argv[1:] if command == first else argv, "cwd": str(ROOT),
|
|
92
|
+
"first_run": command not in ("init", "mcp")}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: list[str] | None = None) -> int:
|
|
96
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
97
|
+
p = plan(argv)
|
|
98
|
+
if "say" in p:
|
|
99
|
+
print(p["say"], file=sys.stderr if p.get("stderr") else sys.stdout)
|
|
100
|
+
return p["code"]
|
|
101
|
+
home = Path(os.environ.get("BENCHWARMERS_HOME", "").strip() or default_home()).expanduser()
|
|
102
|
+
# No bytecode beside the code: an installed folder may not be writable, and
|
|
103
|
+
# an upgrade replaces it anyway.
|
|
104
|
+
env = {**os.environ, "BENCHWARMERS_HOME": str(home), "PYTHONDONTWRITEBYTECODE": "1", "PYTHONUTF8": "1",
|
|
105
|
+
"BENCHWARMERS_LAUNCHER": os.environ.get("BENCHWARMERS_LAUNCHER") or "pip"}
|
|
106
|
+
if p["first_run"] and not home.exists():
|
|
107
|
+
print(f"First run: making your data folder at {home}")
|
|
108
|
+
done = subprocess.run([sys.executable, str(ROOT / SERVERS["init"]), str(home)], env=env, cwd=ROOT)
|
|
109
|
+
if done.returncode:
|
|
110
|
+
return done.returncode
|
|
111
|
+
child = subprocess.Popen([sys.executable, str(ROOT / p["script"]), *p["args"]], env=env, cwd=p["cwd"])
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
return child.wait()
|
|
115
|
+
except KeyboardInterrupt: # the child has it too; let it finish stopping
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|
package/mcp_server.py
ADDED
|
@@ -0,0 +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()
|
package/metrics/alerts.py
CHANGED
|
@@ -1,116 +1,116 @@
|
|
|
1
|
-
"""Things that went wrong and are still wrong, where they will be seen.
|
|
2
|
-
|
|
3
|
-
python metrics/alerts.py # what is currently wrong
|
|
4
|
-
|
|
5
|
-
The scheduled checks already find problems. Until now they announced them by
|
|
6
|
-
exiting non-zero, which Windows records and nobody reads — a model could be
|
|
7
|
-
retired for a fortnight and the only trace would be a number in Task
|
|
8
|
-
Scheduler. Work that reports only to a log nobody opens has not reported.
|
|
9
|
-
|
|
10
|
-
KEYED AND SELF-CLEARING, NOT AN APPEND LOG
|
|
11
|
-
------------------------------------------
|
|
12
|
-
An alert has a stable `key`, so a daily check that keeps finding the same
|
|
13
|
-
retired model updates one row rather than writing thirty. The first sighting is
|
|
14
|
-
kept, because "missing since Tuesday" is the useful part — a model absent for a
|
|
15
|
-
day may be an outage and one absent for a week is gone.
|
|
16
|
-
|
|
17
|
-
**A check that finds nothing CLEARS its alerts.** Otherwise the file only ever
|
|
18
|
-
grows and the dashboard turns into a wall of stale red that everyone learns to
|
|
19
|
-
ignore, which is worse than no alerting at all. `sweep()` exists so a caller
|
|
20
|
-
can say "these are all the problems I found" and have the rest retired
|
|
21
|
-
automatically.
|
|
22
|
-
|
|
23
|
-
WHAT IS NOT AN ALERT
|
|
24
|
-
--------------------
|
|
25
|
-
Anything transient. A rate limit, a timeout, one failed call — those are
|
|
26
|
-
`capacity` and they resolve themselves. An alert is for a state that will not
|
|
27
|
-
fix itself and that somebody has to decide about: a model retired upstream, a
|
|
28
|
-
registration pointing at a name that does not exist, a subscription that no
|
|
29
|
-
longer covers what it did.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
from __future__ import annotations
|
|
33
|
-
|
|
34
|
-
import datetime as dt
|
|
35
|
-
import pathlib
|
|
36
|
-
|
|
37
|
-
import locked
|
|
38
|
-
|
|
39
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
40
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
41
|
-
FILE = DATA / "metrics" / "alerts.json"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _load() -> dict:
|
|
45
|
-
return locked.read_json(FILE)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _update(change):
|
|
49
|
-
"""Read-change-write under a lock: the gateway's seat threads, the
|
|
50
|
-
scheduled checks and the dashboard all raise and clear alerts."""
|
|
51
|
-
return locked.update(FILE, change, sort_keys=True)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def raise_alert(key: str, kind: str, message: str, action: str = "",
|
|
55
|
-
**extra) -> None:
|
|
56
|
-
"""Record a problem, keeping the date it was first seen.
|
|
57
|
-
|
|
58
|
-
`extra` carries what the dashboard needs to ACT on it rather than just
|
|
59
|
-
show it: `alias` for a one-model action, `aliases` for retire-all.
|
|
60
|
-
"""
|
|
61
|
-
today = dt.date.today().isoformat()
|
|
62
|
-
|
|
63
|
-
def put(data: dict) -> None:
|
|
64
|
-
data[key] = {
|
|
65
|
-
"kind": kind,
|
|
66
|
-
"message": message,
|
|
67
|
-
"action": action,
|
|
68
|
-
"first_seen": data.get(key, {}).get("first_seen", today),
|
|
69
|
-
"last_seen": today,
|
|
70
|
-
**extra,
|
|
71
|
-
}
|
|
72
|
-
_update(put)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def clear(key: str) -> bool:
|
|
76
|
-
return _update(lambda data: data.pop(key, None) is not None)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def sweep(prefix: str, still_wrong: set[str]) -> list[str]:
|
|
80
|
-
"""Retire every alert under `prefix` that the caller did not re-raise.
|
|
81
|
-
|
|
82
|
-
This is what stops the file growing for ever. A check reports the complete
|
|
83
|
-
set of problems it found, and anything it used to find but no longer does
|
|
84
|
-
is resolved — a model that came back should not leave a red row behind it.
|
|
85
|
-
"""
|
|
86
|
-
def retire(data: dict) -> list[str]:
|
|
87
|
-
gone = [k for k in data
|
|
88
|
-
if k.startswith(prefix) and k not in still_wrong]
|
|
89
|
-
for key in gone:
|
|
90
|
-
del data[key]
|
|
91
|
-
return gone
|
|
92
|
-
return _update(retire)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def active() -> dict:
|
|
96
|
-
return _load()
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def main() -> None:
|
|
100
|
-
data = active()
|
|
101
|
-
if not data:
|
|
102
|
-
print("Nothing wrong.")
|
|
103
|
-
return
|
|
104
|
-
today = dt.date.today()
|
|
105
|
-
print(f"{len(data)} open:\n")
|
|
106
|
-
for key, a in sorted(data.items(),
|
|
107
|
-
key=lambda kv: kv[1]["first_seen"]):
|
|
108
|
-
days = (today - dt.date.fromisoformat(a["first_seen"])).days
|
|
109
|
-
age = "today" if days == 0 else f"{days}d"
|
|
110
|
-
print(f" [{a['kind']}] {a['message']}")
|
|
111
|
-
print(f" first seen {a['first_seen']} ({age})"
|
|
112
|
-
+ (f" — {a['action']}" if a["action"] else ""))
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if __name__ == "__main__":
|
|
116
|
-
main()
|
|
1
|
+
"""Things that went wrong and are still wrong, where they will be seen.
|
|
2
|
+
|
|
3
|
+
python metrics/alerts.py # what is currently wrong
|
|
4
|
+
|
|
5
|
+
The scheduled checks already find problems. Until now they announced them by
|
|
6
|
+
exiting non-zero, which Windows records and nobody reads — a model could be
|
|
7
|
+
retired for a fortnight and the only trace would be a number in Task
|
|
8
|
+
Scheduler. Work that reports only to a log nobody opens has not reported.
|
|
9
|
+
|
|
10
|
+
KEYED AND SELF-CLEARING, NOT AN APPEND LOG
|
|
11
|
+
------------------------------------------
|
|
12
|
+
An alert has a stable `key`, so a daily check that keeps finding the same
|
|
13
|
+
retired model updates one row rather than writing thirty. The first sighting is
|
|
14
|
+
kept, because "missing since Tuesday" is the useful part — a model absent for a
|
|
15
|
+
day may be an outage and one absent for a week is gone.
|
|
16
|
+
|
|
17
|
+
**A check that finds nothing CLEARS its alerts.** Otherwise the file only ever
|
|
18
|
+
grows and the dashboard turns into a wall of stale red that everyone learns to
|
|
19
|
+
ignore, which is worse than no alerting at all. `sweep()` exists so a caller
|
|
20
|
+
can say "these are all the problems I found" and have the rest retired
|
|
21
|
+
automatically.
|
|
22
|
+
|
|
23
|
+
WHAT IS NOT AN ALERT
|
|
24
|
+
--------------------
|
|
25
|
+
Anything transient. A rate limit, a timeout, one failed call — those are
|
|
26
|
+
`capacity` and they resolve themselves. An alert is for a state that will not
|
|
27
|
+
fix itself and that somebody has to decide about: a model retired upstream, a
|
|
28
|
+
registration pointing at a name that does not exist, a subscription that no
|
|
29
|
+
longer covers what it did.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import datetime as dt
|
|
35
|
+
import pathlib
|
|
36
|
+
|
|
37
|
+
import locked
|
|
38
|
+
|
|
39
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
40
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
41
|
+
FILE = DATA / "metrics" / "alerts.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load() -> dict:
|
|
45
|
+
return locked.read_json(FILE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _update(change):
|
|
49
|
+
"""Read-change-write under a lock: the gateway's seat threads, the
|
|
50
|
+
scheduled checks and the dashboard all raise and clear alerts."""
|
|
51
|
+
return locked.update(FILE, change, sort_keys=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def raise_alert(key: str, kind: str, message: str, action: str = "",
|
|
55
|
+
**extra) -> None:
|
|
56
|
+
"""Record a problem, keeping the date it was first seen.
|
|
57
|
+
|
|
58
|
+
`extra` carries what the dashboard needs to ACT on it rather than just
|
|
59
|
+
show it: `alias` for a one-model action, `aliases` for retire-all.
|
|
60
|
+
"""
|
|
61
|
+
today = dt.date.today().isoformat()
|
|
62
|
+
|
|
63
|
+
def put(data: dict) -> None:
|
|
64
|
+
data[key] = {
|
|
65
|
+
"kind": kind,
|
|
66
|
+
"message": message,
|
|
67
|
+
"action": action,
|
|
68
|
+
"first_seen": data.get(key, {}).get("first_seen", today),
|
|
69
|
+
"last_seen": today,
|
|
70
|
+
**extra,
|
|
71
|
+
}
|
|
72
|
+
_update(put)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clear(key: str) -> bool:
|
|
76
|
+
return _update(lambda data: data.pop(key, None) is not None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sweep(prefix: str, still_wrong: set[str]) -> list[str]:
|
|
80
|
+
"""Retire every alert under `prefix` that the caller did not re-raise.
|
|
81
|
+
|
|
82
|
+
This is what stops the file growing for ever. A check reports the complete
|
|
83
|
+
set of problems it found, and anything it used to find but no longer does
|
|
84
|
+
is resolved — a model that came back should not leave a red row behind it.
|
|
85
|
+
"""
|
|
86
|
+
def retire(data: dict) -> list[str]:
|
|
87
|
+
gone = [k for k in data
|
|
88
|
+
if k.startswith(prefix) and k not in still_wrong]
|
|
89
|
+
for key in gone:
|
|
90
|
+
del data[key]
|
|
91
|
+
return gone
|
|
92
|
+
return _update(retire)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def active() -> dict:
|
|
96
|
+
return _load()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
data = active()
|
|
101
|
+
if not data:
|
|
102
|
+
print("Nothing wrong.")
|
|
103
|
+
return
|
|
104
|
+
today = dt.date.today()
|
|
105
|
+
print(f"{len(data)} open:\n")
|
|
106
|
+
for key, a in sorted(data.items(),
|
|
107
|
+
key=lambda kv: kv[1]["first_seen"]):
|
|
108
|
+
days = (today - dt.date.fromisoformat(a["first_seen"])).days
|
|
109
|
+
age = "today" if days == 0 else f"{days}d"
|
|
110
|
+
print(f" [{a['kind']}] {a['message']}")
|
|
111
|
+
print(f" first seen {a['first_seen']} ({age})"
|
|
112
|
+
+ (f" — {a['action']}" if a["action"] else ""))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|