@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/cli.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""bench: Benchwarmers from the command line, everything the dashboard does
|
|
2
|
+
that makes sense without one.
|
|
3
|
+
|
|
4
|
+
bench status what is running, and where
|
|
5
|
+
bench ask core "review this" -f diff.txt ask a collection, every seat's answer
|
|
6
|
+
bench verdict <call id> --useful --real "..." record what a seat got right and wrong
|
|
7
|
+
bench models | collections | endpoints | keys --help on any of them
|
|
8
|
+
|
|
9
|
+
A THIN CLIENT of a running Benchwarmers server, over HTTP, like the MCP
|
|
10
|
+
server (mcp_server.py): one process writes the data folder, and the same
|
|
11
|
+
commands work against a server on another machine.
|
|
12
|
+
|
|
13
|
+
BENCHWARMERS_URL the server, default http://127.0.0.1:8122
|
|
14
|
+
BENCHWARMERS_ADMIN_TOKEN for the dashboard's routes, where the server has one set
|
|
15
|
+
BENCHWARMERS_KEY a gateway key for ask/collections/verdict, where keys are required
|
|
16
|
+
BENCHWARMERS_TIMEOUT seconds to wait for a panel, default 900
|
|
17
|
+
|
|
18
|
+
--json on any command prints what the server answered, for scripts. Exit
|
|
19
|
+
codes: 0 done, 1 refused or failed, 2 bad usage, 3 no server. Standard
|
|
20
|
+
library only; nothing from metrics/, the data folder being the server's.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import contextlib
|
|
27
|
+
import getpass
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.parse
|
|
34
|
+
import urllib.request
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
PREFIX = "bench/"
|
|
38
|
+
GATEWAY = ("/v1/", "/bench/")
|
|
39
|
+
VERSION = (Path(__file__).resolve().parent / "VERSION").read_text(encoding="utf-8").strip()
|
|
40
|
+
OVERRIDES: dict[str, str] = {} # from flags (--url), over the environment
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Failed(Exception):
|
|
44
|
+
"""Print the message, exit with `code`."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, code: int = 1):
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.code = code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---- talking to the server --------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def env(name: str) -> str:
|
|
54
|
+
"""This client's settings: a flag, else the environment (one place, so a
|
|
55
|
+
test can give the client different ones from the server in its process)."""
|
|
56
|
+
return (OVERRIDES.get(name) or os.environ.get(name) or "").strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def too_old(path: str) -> Failed:
|
|
60
|
+
"""A route this CLI knows and the server does not: an older server."""
|
|
61
|
+
return Failed(f"the server at {base_url()} has no {path}: it is older than this bench ({VERSION})."
|
|
62
|
+
" Upgrade it, or use its dashboard for this.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def base_url() -> str:
|
|
66
|
+
return (env("BENCHWARMERS_URL").rstrip("/") or "http://127.0.0.1:8122").removesuffix("/v1")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def http(method: str, path: str, body: dict | None = None) -> dict:
|
|
70
|
+
"""JSON in, JSON out. The gateway's routes take the gateway key, every
|
|
71
|
+
other route the admin token (metrics/guard.py decides by route too)."""
|
|
72
|
+
headers = {"Accept": "application/json"}
|
|
73
|
+
if secret := env("BENCHWARMERS_KEY" if path.startswith(GATEWAY) else "BENCHWARMERS_ADMIN_TOKEN"):
|
|
74
|
+
headers["Authorization"] = f"Bearer {secret}"
|
|
75
|
+
data = None
|
|
76
|
+
if body is not None:
|
|
77
|
+
data = json.dumps(body).encode()
|
|
78
|
+
headers["Content-Type"] = "application/json"
|
|
79
|
+
timeout = float(env("BENCHWARMERS_TIMEOUT") or 900)
|
|
80
|
+
req = urllib.request.Request(base_url() + path, data=data, headers=headers, method=method)
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
83
|
+
raw = r.read()
|
|
84
|
+
# An unknown GET is answered with the dashboard's page, not a 404.
|
|
85
|
+
if path.startswith("/api/") and "text/html" in (r.headers.get("Content-Type") or ""):
|
|
86
|
+
raise too_old(path)
|
|
87
|
+
return json.loads(raw)
|
|
88
|
+
except urllib.error.HTTPError as e:
|
|
89
|
+
raw = e.read().decode("utf-8", "replace")
|
|
90
|
+
try:
|
|
91
|
+
reply = json.loads(raw)
|
|
92
|
+
except ValueError:
|
|
93
|
+
reply = None
|
|
94
|
+
if e.code == 404 and isinstance(reply, dict) and reply.get("errors") == ["unknown endpoint"]:
|
|
95
|
+
raise too_old(path) from None
|
|
96
|
+
if isinstance(reply, dict) and reply.get("ok") is False and e.code != 401:
|
|
97
|
+
return reply # the dashboard's own refusal: errors[] say why
|
|
98
|
+
message = None
|
|
99
|
+
if isinstance(reply, dict):
|
|
100
|
+
message = "; ".join(reply.get("errors") or []) or (reply.get("error") or {}).get("message")
|
|
101
|
+
if e.code == 401:
|
|
102
|
+
need = "BENCHWARMERS_KEY (a gateway key)" if path.startswith(GATEWAY) else "BENCHWARMERS_ADMIN_TOKEN"
|
|
103
|
+
message = f"{message or raw.strip()[:300]}; set {need}"
|
|
104
|
+
raise Failed(f"{method} {path} refused ({e.code}): {message or raw.strip()[:300] or e.reason}") from None
|
|
105
|
+
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
|
|
106
|
+
reason = getattr(e, "reason", e)
|
|
107
|
+
if isinstance(reason, TimeoutError) or isinstance(e, TimeoutError):
|
|
108
|
+
raise Failed(f"no answer from {base_url()} in {timeout:.0f} s; set BENCHWARMERS_TIMEOUT"
|
|
109
|
+
" for longer panels") from None
|
|
110
|
+
raise Failed(f"Benchwarmers is not running at {base_url()} ({reason}). Start it with"
|
|
111
|
+
" `bench serve` (or its service), or set BENCHWARMERS_URL.", 3) from None
|
|
112
|
+
except ValueError:
|
|
113
|
+
raise Failed(f"{base_url()}{path} did not answer JSON; is BENCHWARMERS_URL a Benchwarmers"
|
|
114
|
+
" server?") from None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def call(method: str, path: str, body: dict | None = None) -> dict:
|
|
118
|
+
"""`http`, failing on a dashboard refusal ({"ok": false, "errors": [...]})."""
|
|
119
|
+
reply = http(method, path, body)
|
|
120
|
+
if isinstance(reply, dict) and reply.get("ok") is False:
|
|
121
|
+
raise Failed("; ".join(reply.get("errors") or ["refused"]))
|
|
122
|
+
return reply
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---- printing ---------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def out(args, reply, human) -> None:
|
|
128
|
+
"""--json prints the reply as the server sent it; otherwise `human(reply)`."""
|
|
129
|
+
if args.json:
|
|
130
|
+
print(json.dumps(reply, indent=2, ensure_ascii=False))
|
|
131
|
+
else:
|
|
132
|
+
human(reply)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def table(rows: list[dict], cols: list[str], empty: str = "none") -> None:
|
|
136
|
+
if not rows:
|
|
137
|
+
print(empty)
|
|
138
|
+
return
|
|
139
|
+
cells = [[str(c) for c in cols]] + [["" if r.get(c) in (None, False) else
|
|
140
|
+
"yes" if r.get(c) is True else
|
|
141
|
+
", ".join(map(str, r[c])) if isinstance(r.get(c), list) else str(r[c])
|
|
142
|
+
for c in cols] for r in rows]
|
|
143
|
+
width = [max(len(row[i]) for row in cells) for i in range(len(cols))]
|
|
144
|
+
for n, row in enumerate(cells):
|
|
145
|
+
print(" ".join(v.ljust(w) for v, w in zip(row, width)).rstrip())
|
|
146
|
+
if n == 0:
|
|
147
|
+
print(" ".join("-" * w for w in width))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def done(reply: dict, said: str = "done") -> None:
|
|
151
|
+
print(reply.get("note") or said)
|
|
152
|
+
for w in reply.get("warnings") or []:
|
|
153
|
+
print(f"note: {w}", file=sys.stderr)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def csv(value: str | None) -> list[str]:
|
|
157
|
+
return [v.strip() for v in (value or "").split(",") if v.strip()]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def confirm(args, what: str) -> None:
|
|
161
|
+
"""Ask before something that cannot be taken back in one step: y in a
|
|
162
|
+
terminal, --yes anywhere; never silently in a script."""
|
|
163
|
+
if getattr(args, "yes", False):
|
|
164
|
+
return
|
|
165
|
+
if not sys.stdin.isatty():
|
|
166
|
+
raise Failed(f"{what}: add --yes to do this without a terminal to ask in", 2)
|
|
167
|
+
print(f"{what}? [y/N] ", end="", file=sys.stderr, flush=True)
|
|
168
|
+
if sys.stdin.readline().strip().lower() not in ("y", "yes"):
|
|
169
|
+
raise Failed("left as it was")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def secret_from(args, what: str) -> str:
|
|
173
|
+
"""A key from stdin (--key-stdin) or a hidden prompt: never an argument,
|
|
174
|
+
which every other user of the machine can read in the process list."""
|
|
175
|
+
if args.key_stdin:
|
|
176
|
+
return sys.stdin.readline().strip()
|
|
177
|
+
if sys.stdin.isatty():
|
|
178
|
+
return getpass.getpass(f"{what} (hidden, Enter for none): ").strip()
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---- commands ---------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def cmd_status(args) -> None:
|
|
185
|
+
reply = call("GET", "/api/status")
|
|
186
|
+
|
|
187
|
+
def human(r):
|
|
188
|
+
print(f"Benchwarmers {r['version']} at {base_url()}")
|
|
189
|
+
print(f" gateway {r['gateway']}")
|
|
190
|
+
print(f" data {r['data']}")
|
|
191
|
+
print(f" models {r['models']} chat, {r['decision_models']} decision, {r['retired']} retired")
|
|
192
|
+
print(f" collections {r['collections']}")
|
|
193
|
+
print(f" endpoints {r['endpoints']} (default {r['default_endpoint']})" if r["default_endpoint"]
|
|
194
|
+
else " endpoints none yet: add one with `bench endpoint add` or on the dashboard")
|
|
195
|
+
print(f" client {r['client']} scheduler {r['scheduler']}")
|
|
196
|
+
print(f" auth {'admin token set' if r['auth'] else 'none (this machine only)'}"
|
|
197
|
+
f"{', gateway keys required' if r['keys_required'] else ''}")
|
|
198
|
+
q = r["onboarding"]
|
|
199
|
+
if q["items"]:
|
|
200
|
+
print(f" onboarding {'running' if q['running'] else 'finished'}, {q['items']} in the queue")
|
|
201
|
+
out(args, reply, human)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_models(args) -> None:
|
|
205
|
+
reply = call("GET", "/api/models")
|
|
206
|
+
rows = [m for m in reply["models"] if args.all or not m["retired"]]
|
|
207
|
+
if args.find:
|
|
208
|
+
rows = [m for m in rows if args.find.lower() in (m["alias"] + " " + m["served"]).lower()]
|
|
209
|
+
if args.role:
|
|
210
|
+
rows = [m for m in rows if m["role"] == args.role]
|
|
211
|
+
reply = {**reply, "models": rows}
|
|
212
|
+
out(args, reply, lambda r: table(r["models"], ["alias", "role", "status", "connection", "endpoint", "served"]
|
|
213
|
+
+ (["retired"] if args.all else []), "no models"))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_model(args) -> None:
|
|
217
|
+
a = args.action
|
|
218
|
+
if a == "rename":
|
|
219
|
+
reply = call("POST", "/api/model", {"alias": args.alias, "new_alias": args.new})
|
|
220
|
+
elif a == "role":
|
|
221
|
+
reply = call("POST", "/api/model", {"alias": args.alias, "new_role": args.role})
|
|
222
|
+
elif a == "ad-hoc":
|
|
223
|
+
reply = call("POST", "/api/model", {"alias": args.alias, "new_ad_hoc": args.state == "on"})
|
|
224
|
+
elif a == "max-tokens":
|
|
225
|
+
n = None if args.tokens in ("none", "") else int(args.tokens)
|
|
226
|
+
reply = call("POST", "/api/model-options", {"alias": args.alias, "max_tokens": n})
|
|
227
|
+
elif a == "retire":
|
|
228
|
+
confirm(args, f"retire {args.alias} (it leaves every collection it is in; restore brings it back)")
|
|
229
|
+
reply = call("POST", "/api/remove", {"alias": args.alias, "reason": args.reason or ""})
|
|
230
|
+
elif a == "restore":
|
|
231
|
+
reply = call("POST", "/api/restore", {"alias": args.alias})
|
|
232
|
+
else: # retry
|
|
233
|
+
reply = call("POST", "/api/retry", {"alias": args.alias})
|
|
234
|
+
out(args, reply, lambda r: done(r, f"{a}: {args.alias} done"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_candidates(args) -> None:
|
|
238
|
+
reply = call("GET", "/api/candidates")
|
|
239
|
+
rows = reply.get("dismissed" if args.dismissed else "candidates") or []
|
|
240
|
+
if args.endpoint:
|
|
241
|
+
rows = [r for r in rows if r.get("endpoint") == args.endpoint]
|
|
242
|
+
if args.find:
|
|
243
|
+
rows = [r for r in rows if args.find.lower() in str(r.get("served", "")).lower()]
|
|
244
|
+
if args.new:
|
|
245
|
+
rows = [r for r in rows if r.get("new")]
|
|
246
|
+
|
|
247
|
+
def human(_r):
|
|
248
|
+
for e in reply.get("errors") or []:
|
|
249
|
+
print(f"note: {e}", file=sys.stderr)
|
|
250
|
+
table(rows, ["served", "endpoint", "group", "new"],
|
|
251
|
+
"nothing dismissed" if args.dismissed else "nothing new to onboard")
|
|
252
|
+
out(args, {**reply, "candidates": rows}, human)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_onboard(args) -> None:
|
|
256
|
+
if args.served in (["status"], ["stop"], ["clear"]):
|
|
257
|
+
verb = args.served[0]
|
|
258
|
+
reply = call("GET", "/api/onboard-queue") if verb == "status" else \
|
|
259
|
+
call("POST", f"/api/onboard-queue/{verb}", {})
|
|
260
|
+
else:
|
|
261
|
+
items = [{"served": s, "endpoint": args.endpoint or ""} for s in args.served]
|
|
262
|
+
reply = call("POST", "/api/onboard-queue", {"items": items})
|
|
263
|
+
if args.wait:
|
|
264
|
+
while reply.get("running"):
|
|
265
|
+
time.sleep(5)
|
|
266
|
+
reply = call("GET", "/api/onboard-queue")
|
|
267
|
+
now = [i for i in reply["items"] if i["state"] == "running"]
|
|
268
|
+
if now and not args.json:
|
|
269
|
+
print(f" {now[0]['served']}: step {now[0].get('step') or 0}/5", file=sys.stderr)
|
|
270
|
+
|
|
271
|
+
def human(r):
|
|
272
|
+
table([{**i, "as": i.get("alias")} for i in r.get("items") or []],
|
|
273
|
+
["served", "endpoint", "state", "as", "note"], "the onboarding queue is empty")
|
|
274
|
+
if r.get("running"):
|
|
275
|
+
print("running; `bench onboard status` to follow, `bench onboard stop` to stop after this one")
|
|
276
|
+
out(args, reply, human)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cmd_dismiss(args) -> None:
|
|
280
|
+
# The picker's key: the bare served name on the default endpoint, served@endpoint elsewhere.
|
|
281
|
+
listed = call("GET", "/api/candidates")
|
|
282
|
+
known = {(r["served"], r["endpoint"]): r["key"]
|
|
283
|
+
for r in (listed.get("candidates") or []) + (listed.get("dismissed") or [])}
|
|
284
|
+
keys = []
|
|
285
|
+
for s in args.served:
|
|
286
|
+
hits = [k for (served, ep), k in known.items() if served == s and (not args.endpoint or ep == args.endpoint)]
|
|
287
|
+
if len(hits) != 1:
|
|
288
|
+
raise Failed(f"{s}: {'not among the candidates' if not hits else 'on several endpoints; add --endpoint'}")
|
|
289
|
+
keys.append(hits[0])
|
|
290
|
+
reply = call("POST", "/api/dismiss", {"served": keys, "undo": args.undo})
|
|
291
|
+
out(args, reply, lambda r: done(r, ("brought back: " if args.undo else "dismissed: ") + ", ".join(keys)))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_collections(args) -> None:
|
|
295
|
+
reply = call("GET", "/v1/models")
|
|
296
|
+
rows = [{"name": m["id"].removeprefix(PREFIX), **m} for m in reply.get("data") or [] if ":" not in m["id"]]
|
|
297
|
+
out(args, reply, lambda r: table(rows, ["name", "members", "explorers", "reply", "description"],
|
|
298
|
+
"no collections; make one with `bench collection save`"))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _collections_admin() -> dict:
|
|
302
|
+
return {c["name"]: c for c in call("GET", "/api/collections").get("collections") or []}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cmd_collection(args) -> None:
|
|
306
|
+
name = args.name.removeprefix(PREFIX)
|
|
307
|
+
if args.action == "show":
|
|
308
|
+
have = _collections_admin()
|
|
309
|
+
if name not in have:
|
|
310
|
+
raise Failed(f"no collection {name!r}; have: {', '.join(sorted(have)) or 'none'}")
|
|
311
|
+
return out(args, have[name], lambda c: print(json.dumps(c, indent=2, ensure_ascii=False)))
|
|
312
|
+
if args.action == "rm":
|
|
313
|
+
confirm(args, f"delete collection {name} (callers of bench/{name} start failing)")
|
|
314
|
+
reply = call("POST", "/api/collection/remove", {"name": name})
|
|
315
|
+
return out(args, reply, lambda r: done(r, f"deleted collection {name}"))
|
|
316
|
+
# save: what is given replaces; what is not keeps the collection's own (a
|
|
317
|
+
# new one starts empty), so one flag can be changed without retyping the rest.
|
|
318
|
+
old = _collections_admin().get(name) or {}
|
|
319
|
+
members = old.get("members") or []
|
|
320
|
+
if args.members is not None:
|
|
321
|
+
members = csv(args.members)
|
|
322
|
+
members = [m for m in members if m not in csv(args.remove)] + \
|
|
323
|
+
[m for m in csv(args.add) if m not in members]
|
|
324
|
+
body = {"name": name, "members": members,
|
|
325
|
+
"shape": args.shape if args.shape is not None else old.get("shape") or "",
|
|
326
|
+
"description": args.description if args.description is not None else old.get("description") or "",
|
|
327
|
+
"tags": csv(args.tags) if args.tags is not None else old.get("tags") or [],
|
|
328
|
+
"reply": args.reply or old.get("reply") or "all",
|
|
329
|
+
"merger": args.merger if args.merger is not None else old.get("merger") or ""}
|
|
330
|
+
reply = call("POST", "/api/collection", body)
|
|
331
|
+
out(args, reply, lambda r: done(r, f"saved collection {name}: {', '.join(members)}"))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def read_prompt(args) -> str:
|
|
335
|
+
parts = [" ".join(args.prompt)] if args.prompt and args.prompt != ["-"] else []
|
|
336
|
+
if args.prompt == ["-"] or (not args.prompt and not sys.stdin.isatty()):
|
|
337
|
+
parts.append(sys.stdin.read())
|
|
338
|
+
for f in args.file or []:
|
|
339
|
+
p = Path(f)
|
|
340
|
+
try:
|
|
341
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
342
|
+
except OSError as e:
|
|
343
|
+
raise Failed(f"cannot read {f}: {e.strerror}") from None
|
|
344
|
+
parts.append(f"`{p.name}`:\n```\n{text.rstrip()}\n```")
|
|
345
|
+
prompt = "\n\n".join(x for x in parts if x.strip())
|
|
346
|
+
if not prompt.strip():
|
|
347
|
+
raise Failed("nothing to ask: give a prompt, -f FILE, or pipe one in", 2)
|
|
348
|
+
return prompt
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cmd_ask(args) -> None:
|
|
352
|
+
messages = [{"role": "user", "content": read_prompt(args)}]
|
|
353
|
+
if args.system:
|
|
354
|
+
messages.insert(0, {"role": "system", "content": args.system})
|
|
355
|
+
name = args.collection if args.collection.startswith(PREFIX) else PREFIX + args.collection
|
|
356
|
+
body = {"model": name, "messages": messages}
|
|
357
|
+
if args.merged is not None:
|
|
358
|
+
body["bench"] = {"reply": "merged" if args.merged else "all"}
|
|
359
|
+
if args.with_:
|
|
360
|
+
body["with"] = args.with_
|
|
361
|
+
if not args.json and sys.stderr.isatty():
|
|
362
|
+
print(f"asking {name}; a panel can take minutes...", file=sys.stderr)
|
|
363
|
+
reply = http("POST", "/v1/chat/completions", body)
|
|
364
|
+
|
|
365
|
+
def human(r):
|
|
366
|
+
print(r["choices"][0]["message"]["content"])
|
|
367
|
+
# The seats to stderr: `bench ask ... > review.md` keeps the answers only.
|
|
368
|
+
seats = (r.get("bench") or {}).get("seats") or []
|
|
369
|
+
if seats:
|
|
370
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
371
|
+
print("\nSeats (verdict each one you check: bench verdict <call> --useful|--not-useful):")
|
|
372
|
+
table(seats, ["call", "seat", "alias", "status", "seconds"])
|
|
373
|
+
out(args, reply, human)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def cmd_verdict(args) -> None:
|
|
377
|
+
body = {"call": args.call, "useful": args.useful, "note": args.note or "",
|
|
378
|
+
"real": args.real or [], "false": args.false or []}
|
|
379
|
+
reply = call("POST", "/bench/verdict", body)
|
|
380
|
+
out(args, reply, lambda r: done(r, f"recorded {args.call}: {'useful' if args.useful else 'not useful'},"
|
|
381
|
+
f" {len(body['real'])} real, {len(body['false'])} false"))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cmd_endpoints(args) -> None:
|
|
385
|
+
reply = call("GET", "/api/endpoints")
|
|
386
|
+
out(args, reply, lambda r: table(r["endpoints"], ["name", "base", "kind", "hub", "default", "key_set", "models"],
|
|
387
|
+
"no endpoints; add one with `bench endpoint add`"))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def cmd_endpoint(args) -> None:
|
|
391
|
+
a = args.action
|
|
392
|
+
if a in ("add", "key", "test"):
|
|
393
|
+
have = {e["name"]: e for e in call("GET", "/api/endpoints")["endpoints"]}
|
|
394
|
+
base = args.base or (have.get(args.name) or {}).get("base") or ""
|
|
395
|
+
if not base:
|
|
396
|
+
raise Failed(f"give the base URL: bench endpoint {a} {args.name} --base https://...", 2)
|
|
397
|
+
body = {"name": args.name, "base": base, "key": secret_from(args, f"API key for {args.name}"),
|
|
398
|
+
"new": a == "add", "kind": args.kind or "", "hub": args.hub or "", "test": a == "test"}
|
|
399
|
+
reply = call("POST", "/api/endpoint", body)
|
|
400
|
+
elif a == "rm":
|
|
401
|
+
confirm(args, f"remove endpoint {args.name}" + (" and retire every model on it" if args.with_models else ""))
|
|
402
|
+
reply = call("POST", "/api/endpoint/remove", {"name": args.name, "with_models": args.with_models})
|
|
403
|
+
else: # default
|
|
404
|
+
reply = call("POST", "/api/endpoint/default", {"name": args.name})
|
|
405
|
+
out(args, reply, lambda r: done(r, f"{a}: {args.name} done"))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def cmd_keys(args) -> None:
|
|
409
|
+
reply = call("GET", "/api/gateway-keys")
|
|
410
|
+
|
|
411
|
+
def human(r):
|
|
412
|
+
print("gateway keys are " + ("required" if r["required"] else "not required on this machine"))
|
|
413
|
+
table(r["keys"], ["name", "hint", "collections", "rpm", "created", "last_used"], "no gateway keys")
|
|
414
|
+
out(args, reply, human)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def cmd_key(args) -> None:
|
|
418
|
+
if args.action == "revoke":
|
|
419
|
+
confirm(args, f"revoke gateway key {args.name} (whatever uses it stops working)")
|
|
420
|
+
reply = call("POST", "/api/gateway-key/remove", {"name": args.name})
|
|
421
|
+
return out(args, reply, lambda r: done(r, f"revoked {args.name}"))
|
|
422
|
+
reply = call("POST", "/api/gateway-key", {"name": args.name, "collections": csv(args.collections),
|
|
423
|
+
"rpm": args.rpm})
|
|
424
|
+
|
|
425
|
+
def human(r):
|
|
426
|
+
print(r.get("key") or json.dumps(r))
|
|
427
|
+
print(f"the key for {args.name}, shown this once: keep it now", file=sys.stderr)
|
|
428
|
+
out(args, reply, human)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cmd_settings(args) -> None:
|
|
432
|
+
a = args.action
|
|
433
|
+
if a == "gateway":
|
|
434
|
+
reply = call("POST", "/api/gateway-settings", {"max_calls": args.max_calls,
|
|
435
|
+
"max_explorers": args.max_explorers})
|
|
436
|
+
elif a == "client":
|
|
437
|
+
reply = call("POST", "/api/client", {"client": args.client})
|
|
438
|
+
else: # scheduler
|
|
439
|
+
status = call("GET", "/api/status")
|
|
440
|
+
jobs = dict(status.get("scheduler_jobs") or {})
|
|
441
|
+
for j in csv(args.on):
|
|
442
|
+
jobs[j] = True
|
|
443
|
+
for j in csv(args.off):
|
|
444
|
+
jobs[j] = False
|
|
445
|
+
reply = call("POST", "/api/scheduler", {"mode": args.mode or status["scheduler"], "jobs": jobs})
|
|
446
|
+
out(args, reply, lambda r: done(r, f"saved {a} settings"))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---- the parser -------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
EXAMPLES = {
|
|
452
|
+
"": """examples:
|
|
453
|
+
bench status
|
|
454
|
+
bench ask core "Where can this return a stale value?" -f cache.py
|
|
455
|
+
git diff | bench ask core "Review this diff"
|
|
456
|
+
bench verdict p20261022-101500-ab12.member1 --useful --real "the race in refresh()"
|
|
457
|
+
bench models --find mercury
|
|
458
|
+
bench --url http://bench.lan:8122 collections
|
|
459
|
+
|
|
460
|
+
Starting the server: bench serve | gateway | init | mcp (benchwarmers --help).
|
|
461
|
+
Where another program is called bench (the Frappe framework's), type benchwarmers.""",
|
|
462
|
+
"ask": """examples:
|
|
463
|
+
bench ask core "Is this regex safe on untrusted input?" -f validate.py
|
|
464
|
+
git diff main | bench ask reviewers "Where does this change break callers?"
|
|
465
|
+
bench ask core --merged "Summarise the disagreements" < notes.md
|
|
466
|
+
bench ask core --with mercury-3 "..." seat a model you want to try
|
|
467
|
+
|
|
468
|
+
The answers go to stdout and the seats' table to stderr, so
|
|
469
|
+
bench ask core "..." > review.md
|
|
470
|
+
keeps only the answers. Then judge each seat you checked:
|
|
471
|
+
bench verdict <call> --useful|--not-useful""",
|
|
472
|
+
"verdict": """examples:
|
|
473
|
+
bench verdict p20261022-101500-ab12.member1 --useful --note "found the real bug" \\
|
|
474
|
+
--real "refresh() races with evict()" --false "the query is injectable\"""",
|
|
475
|
+
"model": """examples:
|
|
476
|
+
bench model role mercury-3 code
|
|
477
|
+
bench model rename deepseek-v4.1-flash deepseek-flash
|
|
478
|
+
bench model max-tokens inkling-free 8000 (none: the endpoint's own)
|
|
479
|
+
bench model retire old-model --reason "deprecated by its provider"
|
|
480
|
+
bench model restore old-model""",
|
|
481
|
+
"onboard": """examples:
|
|
482
|
+
bench candidates --new
|
|
483
|
+
bench onboard minimax/minimax-m3.1-flash:free --wait
|
|
484
|
+
bench onboard a/one:free b/two:free --endpoint openrouter
|
|
485
|
+
bench onboard status | stop | clear""",
|
|
486
|
+
"collection": """examples:
|
|
487
|
+
bench collection save reviewers --members a,b,c --description "adversarial review" --tags review
|
|
488
|
+
bench collection save reviewers --add d --remove a (the rest stays as it was)
|
|
489
|
+
bench collection save reviewers --reply merged --merger a
|
|
490
|
+
bench collection show reviewers
|
|
491
|
+
bench collection rm reviewers --yes""",
|
|
492
|
+
"endpoint": """examples:
|
|
493
|
+
bench endpoint add openrouter --base https://openrouter.ai/api/v1 (asks for the key, hidden)
|
|
494
|
+
printf %s "$KEY" | bench endpoint key openrouter --key-stdin
|
|
495
|
+
bench endpoint test groq --base https://api.groq.com/openai/v1
|
|
496
|
+
bench endpoint default openwebui
|
|
497
|
+
bench endpoint rm old --with-models --yes""",
|
|
498
|
+
"key": """examples:
|
|
499
|
+
bench key create open-webui --collections core,fast --rpm 30 (printed once: keep it)
|
|
500
|
+
bench key revoke open-webui --yes""",
|
|
501
|
+
"settings": """examples:
|
|
502
|
+
bench settings gateway --max-calls 6 --max-explorers 2
|
|
503
|
+
bench settings client builtin
|
|
504
|
+
bench settings scheduler run --off perf,suite""",
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def parser() -> argparse.ArgumentParser:
|
|
509
|
+
# On every command as well as before it (bench --json status, bench status --json).
|
|
510
|
+
# SUPPRESS on the copies: a sub-command's default must not overwrite one given first.
|
|
511
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
512
|
+
common.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
|
|
513
|
+
help="print the server's reply as JSON")
|
|
514
|
+
common.add_argument("--url", default=argparse.SUPPRESS, metavar="URL",
|
|
515
|
+
help="the server (default: BENCHWARMERS_URL, else http://127.0.0.1:8122)")
|
|
516
|
+
fmt = argparse.RawDescriptionHelpFormatter
|
|
517
|
+
p = argparse.ArgumentParser(prog="bench", formatter_class=fmt, epilog=EXAMPLES[""],
|
|
518
|
+
description="Benchwarmers from the command line: a client of a running server."
|
|
519
|
+
" Tokens come from BENCHWARMERS_ADMIN_TOKEN and BENCHWARMERS_KEY, never"
|
|
520
|
+
" an argument.")
|
|
521
|
+
p.add_argument("--json", action="store_true", default=False, help="print the server's reply as JSON")
|
|
522
|
+
p.add_argument("--url", default=None, metavar="URL",
|
|
523
|
+
help="the server (default: BENCHWARMERS_URL, else http://127.0.0.1:8122)")
|
|
524
|
+
p.add_argument("--version", action="version", version=f"bench {VERSION}")
|
|
525
|
+
sub = p.add_subparsers(dest="command", required=True, metavar="command")
|
|
526
|
+
|
|
527
|
+
def cmd(name, fn, help_):
|
|
528
|
+
s = sub.add_parser(name, help=help_, description=help_, parents=[common], formatter_class=fmt,
|
|
529
|
+
epilog=EXAMPLES.get(name))
|
|
530
|
+
s.set_defaults(fn=fn)
|
|
531
|
+
return s
|
|
532
|
+
|
|
533
|
+
def actions(s, fn):
|
|
534
|
+
a = s.add_subparsers(dest="action", required=True, metavar="action")
|
|
535
|
+
|
|
536
|
+
def act(name, help_, destructive=False):
|
|
537
|
+
x = a.add_parser(name, help=help_, description=help_, parents=[common])
|
|
538
|
+
x.set_defaults(fn=fn)
|
|
539
|
+
if destructive:
|
|
540
|
+
x.add_argument("-y", "--yes", action="store_true", help="do it without asking")
|
|
541
|
+
return x
|
|
542
|
+
return act
|
|
543
|
+
|
|
544
|
+
cmd("status", cmd_status, "what is running, and where")
|
|
545
|
+
|
|
546
|
+
s = cmd("ask", cmd_ask, "ask a collection: every seat answers")
|
|
547
|
+
s.add_argument("collection", help="a collection, e.g. core (bench collections)")
|
|
548
|
+
s.add_argument("prompt", nargs="*", help="the question; - or a pipe reads stdin")
|
|
549
|
+
s.add_argument("-f", "--file", action="append", help="add a file's text to the prompt (repeatable)")
|
|
550
|
+
s.add_argument("--system", help="a system message for every seat")
|
|
551
|
+
g = s.add_mutually_exclusive_group()
|
|
552
|
+
g.add_argument("--merged", dest="merged", action="store_true", default=None, help="one reconciled answer")
|
|
553
|
+
g.add_argument("--all", dest="merged", action="store_false", help="every answer (the usual)")
|
|
554
|
+
s.add_argument("--with", dest="with_", action="append", metavar="ALIAS", help="seat a model as an explorer")
|
|
555
|
+
|
|
556
|
+
s = cmd("verdict", cmd_verdict, "judge one seat's answer by its call id")
|
|
557
|
+
s.add_argument("call")
|
|
558
|
+
g = s.add_mutually_exclusive_group(required=True)
|
|
559
|
+
g.add_argument("--useful", dest="useful", action="store_true")
|
|
560
|
+
g.add_argument("--not-useful", dest="useful", action="store_false")
|
|
561
|
+
s.add_argument("--note")
|
|
562
|
+
s.add_argument("--real", action="append", metavar="CLAIM", help="a claim it got right (repeatable)")
|
|
563
|
+
s.add_argument("--false", action="append", metavar="CLAIM", help="a claim that is untrue (repeatable)")
|
|
564
|
+
|
|
565
|
+
s = cmd("models", cmd_models, "the models on the bench")
|
|
566
|
+
s.add_argument("--all", action="store_true", help="retired ones too")
|
|
567
|
+
s.add_argument("--find", help="alias or served name contains this")
|
|
568
|
+
s.add_argument("--role", help="only this role")
|
|
569
|
+
|
|
570
|
+
act = actions(cmd("model", cmd_model, "change one model"), cmd_model)
|
|
571
|
+
m = act("rename", "give it a new alias")
|
|
572
|
+
m.add_argument("alias"), m.add_argument("new")
|
|
573
|
+
m = act("role", "what it is for (general, code, review, ...)")
|
|
574
|
+
m.add_argument("alias"), m.add_argument("role")
|
|
575
|
+
m = act("ad-hoc", "only when asked for by name")
|
|
576
|
+
m.add_argument("alias"), m.add_argument("state", choices=["on", "off"])
|
|
577
|
+
m = act("max-tokens", "the most an answer may use; none for the endpoint's")
|
|
578
|
+
m.add_argument("alias"), m.add_argument("tokens")
|
|
579
|
+
m = act("retire", "take it off the bench and out of its collections (restore undoes it)", destructive=True)
|
|
580
|
+
m.add_argument("alias"), m.add_argument("--reason")
|
|
581
|
+
act("restore", "undo a retire").add_argument("alias")
|
|
582
|
+
act("retry", "one fresh call to a model on a retiring alert").add_argument("alias")
|
|
583
|
+
|
|
584
|
+
s = cmd("candidates", cmd_candidates, "what the endpoints serve that could be onboarded")
|
|
585
|
+
s.add_argument("--endpoint"), s.add_argument("--find")
|
|
586
|
+
s.add_argument("--new", action="store_true", help="only ones new since the daily check")
|
|
587
|
+
s.add_argument("--dismissed", action="store_true", help="the dismissed ones instead")
|
|
588
|
+
|
|
589
|
+
s = cmd("onboard", cmd_onboard, "onboard models (queued, one at a time), or: onboard status|stop|clear")
|
|
590
|
+
s.add_argument("served", nargs="+", help="served names, as `bench candidates` lists them")
|
|
591
|
+
s.add_argument("--endpoint", help="the endpoint they are on; the default if omitted")
|
|
592
|
+
s.add_argument("--wait", action="store_true", help="stay until the queue finishes")
|
|
593
|
+
|
|
594
|
+
s = cmd("dismiss", cmd_dismiss, "stop offering served models as candidates")
|
|
595
|
+
s.add_argument("served", nargs="+"), s.add_argument("--endpoint"), s.add_argument("--undo", action="store_true")
|
|
596
|
+
|
|
597
|
+
cmd("collections", cmd_collections, "the collections a caller can ask")
|
|
598
|
+
|
|
599
|
+
act = actions(cmd("collection", cmd_collection, "show, save or delete one collection"), cmd_collection)
|
|
600
|
+
act("show", "everything about one").add_argument("name")
|
|
601
|
+
act("rm", "delete one", destructive=True).add_argument("name")
|
|
602
|
+
c = act("save", "create one, or change what is given")
|
|
603
|
+
c.add_argument("name")
|
|
604
|
+
c.add_argument("--members", help="comma-separated aliases: the whole list")
|
|
605
|
+
c.add_argument("--add", help="comma-separated aliases to add")
|
|
606
|
+
c.add_argument("--remove", help="comma-separated aliases to take out")
|
|
607
|
+
c.add_argument("--shape", help="the task shape explorers are picked for; empty for automatic")
|
|
608
|
+
c.add_argument("--description", help="what it is for: callers read it to choose")
|
|
609
|
+
c.add_argument("--tags", help="comma-separated")
|
|
610
|
+
c.add_argument("--reply", choices=["all", "merged"])
|
|
611
|
+
c.add_argument("--merger", help="the alias that merges, for --reply merged")
|
|
612
|
+
|
|
613
|
+
cmd("endpoints", cmd_endpoints, "where models are served from")
|
|
614
|
+
|
|
615
|
+
act = actions(cmd("endpoint", cmd_endpoint, "add, test, re-key, remove or make default an endpoint"),
|
|
616
|
+
cmd_endpoint)
|
|
617
|
+
for name, help_ in (("add", "add one (tested first)"), ("test", "test without saving"),
|
|
618
|
+
("key", "change one's key")):
|
|
619
|
+
e = act(name, help_)
|
|
620
|
+
e.add_argument("name"), e.add_argument("--base", help="its base URL, e.g. https://openrouter.ai/api/v1")
|
|
621
|
+
e.add_argument("--kind"), e.add_argument("--hub")
|
|
622
|
+
e.add_argument("--key-stdin", action="store_true", help="read the key from stdin; else a hidden prompt")
|
|
623
|
+
e = act("rm", "remove one", destructive=True)
|
|
624
|
+
e.add_argument("name"), e.add_argument("--with-models", action="store_true", help="retire its models too")
|
|
625
|
+
act("default", "make one the default").add_argument("name")
|
|
626
|
+
|
|
627
|
+
cmd("keys", cmd_keys, "the gateway keys (names and hints, never a key)")
|
|
628
|
+
|
|
629
|
+
act = actions(cmd("key", cmd_key, "make or revoke a gateway key"), cmd_key)
|
|
630
|
+
k = act("create", "a new key, printed once")
|
|
631
|
+
k.add_argument("name"), k.add_argument("--collections", help="comma-separated; all if omitted")
|
|
632
|
+
k.add_argument("--rpm", type=int, help="chat completions a minute; no cap if omitted")
|
|
633
|
+
act("revoke", "revoke one by name: whatever uses it stops working", destructive=True).add_argument("name")
|
|
634
|
+
|
|
635
|
+
act = actions(cmd("settings", cmd_settings, "gateway limits, the model client, the scheduler"), cmd_settings)
|
|
636
|
+
t = act("gateway", "how many calls a panel may make")
|
|
637
|
+
t.add_argument("--max-calls", type=int, required=True), t.add_argument("--max-explorers", type=int, required=True)
|
|
638
|
+
act("client", "llm or builtin").add_argument("client")
|
|
639
|
+
t = act("scheduler", "its mode, and jobs on or off")
|
|
640
|
+
t.add_argument("mode", nargs="?", choices=["off", "log-only", "run"])
|
|
641
|
+
t.add_argument("--on", help="comma-separated jobs to run"), t.add_argument("--off", help="comma-separated jobs to skip")
|
|
642
|
+
return p
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def main(argv: list[str] | None = None) -> int:
|
|
646
|
+
for stream in (sys.stdout, sys.stderr):
|
|
647
|
+
try:
|
|
648
|
+
stream.reconfigure(encoding="utf-8")
|
|
649
|
+
except (AttributeError, ValueError):
|
|
650
|
+
pass
|
|
651
|
+
args = parser().parse_args(argv)
|
|
652
|
+
OVERRIDES.clear()
|
|
653
|
+
if args.url:
|
|
654
|
+
OVERRIDES["BENCHWARMERS_URL"] = args.url
|
|
655
|
+
try:
|
|
656
|
+
args.fn(args)
|
|
657
|
+
except Failed as e:
|
|
658
|
+
print(f"bench: {e}", file=sys.stderr)
|
|
659
|
+
return e.code
|
|
660
|
+
except KeyboardInterrupt:
|
|
661
|
+
return 130
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
if __name__ == "__main__":
|
|
666
|
+
sys.exit(main())
|