@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.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.
- package/.github/logo-dark.svg +49 -0
- package/.github/logo.svg +49 -0
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +33 -11
- package/PRIVACY.md +20 -6
- package/README.md +46 -11
- package/SECURITY.md +20 -10
- package/USAGE.md +71 -9
- package/VERSION +1 -1
- package/gateway.py +93 -82
- package/metrics/bench_collections.py +27 -3
- package/metrics/catalog.py +52 -4
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/endpoints.py +4 -6
- package/metrics/guard.py +6 -4
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/route.py +9 -1
- package/metrics/scheduler.py +11 -1
- package/metrics/test_decide.py +275 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +44 -3
- package/scripts/test_connect.py +152 -3
- package/scripts/test_first_run.py +4 -2
- package/scripts/test_formats.py +39 -0
- package/scripts/test_serve_edit.py +308 -4
- package/serve.py +1672 -141
- package/suite/README.md +5 -2
- package/suite/run.py +226 -6
- package/suite/test_pick.py +15 -0
- package/suite/test_score.py +82 -1
- package/ui/bench.css +44 -0
- package/ui/bench.js +910 -203
package/metrics/endpoints.py
CHANGED
|
@@ -131,8 +131,9 @@ def load() -> dict[str, dict]:
|
|
|
131
131
|
else's machine is a timeout with no explanation. Whatever needs an
|
|
132
132
|
endpoint asks default() (base, key, models and yaml_block all do), and
|
|
133
133
|
that refuses, so a job run before any endpoint exists says what to do."""
|
|
134
|
+
import locked
|
|
134
135
|
try:
|
|
135
|
-
data = json.loads(
|
|
136
|
+
data = json.loads(locked.read_text(FILE)) # inside a render, from its snapshot
|
|
136
137
|
except FileNotFoundError:
|
|
137
138
|
return {}
|
|
138
139
|
except (OSError, ValueError) as exc:
|
|
@@ -147,11 +148,8 @@ def connections() -> dict:
|
|
|
147
148
|
ties, names for bare-IP hosts, models.dev ids, providers whose free tier
|
|
148
149
|
works only in their own client. {} when absent: a fresh
|
|
149
150
|
install has no such facts, and none are assumed."""
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
except (OSError, ValueError):
|
|
153
|
-
return {}
|
|
154
|
-
return data if isinstance(data, dict) else {}
|
|
151
|
+
import locked
|
|
152
|
+
return locked.read_json(CONNECTIONS) # inside a render, from its snapshot
|
|
155
153
|
|
|
156
154
|
|
|
157
155
|
_default: tuple = (None, "")
|
package/metrics/guard.py
CHANGED
|
@@ -146,9 +146,11 @@ def cors_origin(headers, port: int) -> str:
|
|
|
146
146
|
return origin if origin.lower() in origins(port) else ""
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
def check(headers, port: int, post: bool, max_body: int, cors: bool = False
|
|
149
|
+
def check(headers, port: int, post: bool, max_body: int, cors: bool = False,
|
|
150
|
+
kind: str = "application/json") -> int:
|
|
150
151
|
"""Raise Refused, or return how many body bytes to read (0 for a GET).
|
|
151
|
-
`cors`: a gateway route, where an allowed origin may be cross-site.
|
|
152
|
+
`cors`: a gateway route, where an allowed origin may be cross-site.
|
|
153
|
+
`kind`: the body's type; never one a plain form can send (an import's zip)."""
|
|
152
154
|
host = (headers.get("Host") or "").strip().lower()
|
|
153
155
|
if host not in hosts(port):
|
|
154
156
|
raise Refused(403, f"unknown Host {host or '(none)'!r}; set BENCHWARMERS_PUBLIC_URL"
|
|
@@ -161,8 +163,8 @@ def check(headers, port: int, post: bool, max_body: int, cors: bool = False) ->
|
|
|
161
163
|
raise Refused(403, "cross-site requests are refused")
|
|
162
164
|
if origin and origin not in allowed:
|
|
163
165
|
raise Refused(403, f"Origin {origin!r} is not allowed; set BENCH_ALLOWED_ORIGINS to add one")
|
|
164
|
-
if not (headers.get("Content-Type") or "").lower().startswith(
|
|
165
|
-
raise Refused(415, "send Content-Type:
|
|
166
|
+
if not (headers.get("Content-Type") or "").lower().startswith(kind):
|
|
167
|
+
raise Refused(415, f"send Content-Type: {kind}")
|
|
166
168
|
try:
|
|
167
169
|
length = int(headers.get("Content-Length") or 0)
|
|
168
170
|
except ValueError:
|
package/metrics/ledger.py
CHANGED
|
@@ -17,7 +17,9 @@ are what a provider's request limits count. `status` is ok, empty, error,
|
|
|
17
17
|
timeout or refused (not sent: pacing, or the free-tier guard). Tokens are the
|
|
18
18
|
provider's own figures from the reply's usage, absent when it gave none.
|
|
19
19
|
`caller` is the script that made the call (gateway, perf/run, suite/run, ...)
|
|
20
|
-
and `purpose` what the caller said it was for, when it said.
|
|
20
|
+
and `purpose` what the caller said it was for, when it said. A failed call
|
|
21
|
+
(status error) carries its `error`, start and end (client.clip), from
|
|
22
|
+
2026-11-08: the uptime report classifies it (suite/run.uptime_class).
|
|
21
23
|
|
|
22
24
|
A FILE A MONTH
|
|
23
25
|
--------------
|
package/metrics/locked.py
CHANGED
|
@@ -26,6 +26,7 @@ import json
|
|
|
26
26
|
import os
|
|
27
27
|
import pathlib
|
|
28
28
|
import tempfile
|
|
29
|
+
import threading
|
|
29
30
|
import time
|
|
30
31
|
|
|
31
32
|
if os.name == "nt":
|
|
@@ -71,6 +72,7 @@ def write_atomic(path: pathlib.Path, text: str) -> None:
|
|
|
71
72
|
writers never share it (a fixed `.part` name let one writer move another's
|
|
72
73
|
half-written file into place)."""
|
|
73
74
|
path = pathlib.Path(path)
|
|
75
|
+
_forget(path)
|
|
74
76
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
77
|
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".part")
|
|
76
78
|
try:
|
|
@@ -90,10 +92,85 @@ def write_atomic(path: pathlib.Path, text: str) -> None:
|
|
|
90
92
|
os.remove(tmp)
|
|
91
93
|
|
|
92
94
|
|
|
95
|
+
_seen = threading.local()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@contextlib.contextmanager
|
|
99
|
+
def snapshot():
|
|
100
|
+
"""Inside it, this thread reads each file from disk once and reuses the
|
|
101
|
+
text: one dashboard render asked for the same dozen config files 11,000
|
|
102
|
+
times, seconds of opens on Windows. Text, not the parsed object, so a
|
|
103
|
+
caller changing what it parsed changes nothing for the next one. Other
|
|
104
|
+
threads (the gateway, a POST) read the disk as before; a write from this
|
|
105
|
+
thread drops its copy."""
|
|
106
|
+
if getattr(_seen, "texts", None) is not None: # already inside one
|
|
107
|
+
yield
|
|
108
|
+
return
|
|
109
|
+
_seen.texts = {}
|
|
110
|
+
try:
|
|
111
|
+
yield
|
|
112
|
+
finally:
|
|
113
|
+
_seen.texts = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def once(key, compute):
|
|
117
|
+
"""compute(); inside snapshot(), its first answer for `key` (an OSError as
|
|
118
|
+
first raised). Only for answers no caller can change, such as a string."""
|
|
119
|
+
texts = getattr(_seen, "texts", None)
|
|
120
|
+
if texts is None:
|
|
121
|
+
return compute()
|
|
122
|
+
if key not in texts:
|
|
123
|
+
try:
|
|
124
|
+
texts[key] = compute()
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
texts[key] = exc # missing stays missing, as the same error
|
|
127
|
+
if isinstance(texts[key], OSError):
|
|
128
|
+
raise texts[key]
|
|
129
|
+
return texts[key]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_text(path) -> str:
|
|
133
|
+
"""The file's text (OSError if it cannot be read); inside snapshot(), as
|
|
134
|
+
first read."""
|
|
135
|
+
return once(str(path), lambda: pathlib.Path(path).read_text(encoding="utf-8"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def appended(path, mark: dict) -> tuple[bool, list[str]]:
|
|
139
|
+
"""(start over?, lines) for a log that is only ever appended to: the
|
|
140
|
+
whole lines added since `mark` last saw it. The first time, or when the
|
|
141
|
+
file was replaced or rewritten rather than appended to (shorter, or the
|
|
142
|
+
bytes before where it stopped differ), it is True and every line. A last
|
|
143
|
+
line still missing its newline waits for the next call. `mark` is the
|
|
144
|
+
caller's own dict, kept between calls; hold one lock around this call
|
|
145
|
+
and whatever folds the lines in."""
|
|
146
|
+
path = str(path)
|
|
147
|
+
at, before = (mark.get("at", 0), mark.get("before", b"")) if mark.get("path") == path else (0, b"")
|
|
148
|
+
try:
|
|
149
|
+
with open(path, "rb") as fh:
|
|
150
|
+
size = fh.seek(0, os.SEEK_END)
|
|
151
|
+
if at:
|
|
152
|
+
fh.seek(max(at - len(before), 0))
|
|
153
|
+
if size < at or fh.read(len(before)) != before:
|
|
154
|
+
at, before = 0, b""
|
|
155
|
+
fh.seek(at)
|
|
156
|
+
data = fh.read()
|
|
157
|
+
except OSError:
|
|
158
|
+
mark.clear()
|
|
159
|
+
return True, []
|
|
160
|
+
end = data.rfind(b"\n") + 1
|
|
161
|
+
mark.update(path=path, at=at + end, before=(before + data[:end])[-64:])
|
|
162
|
+
return at == 0, data[:end].decode("utf-8", "replace").splitlines()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _forget(path) -> None:
|
|
166
|
+
if (texts := getattr(_seen, "texts", None)) is not None:
|
|
167
|
+
texts.pop(str(path), None)
|
|
168
|
+
|
|
169
|
+
|
|
93
170
|
def read_json(path: pathlib.Path) -> dict:
|
|
94
171
|
"""The file's object, or {} when it is missing, unreadable or not an object."""
|
|
95
172
|
try:
|
|
96
|
-
data = json.loads(
|
|
173
|
+
data = json.loads(read_text(path))
|
|
97
174
|
except (OSError, ValueError):
|
|
98
175
|
return {}
|
|
99
176
|
return data if isinstance(data, dict) else {}
|
|
@@ -105,6 +182,7 @@ def update(path: pathlib.Path, change, **dump):
|
|
|
105
182
|
json.dumps (indent defaults to 2)."""
|
|
106
183
|
dump.setdefault("indent", 2)
|
|
107
184
|
with lock(path):
|
|
185
|
+
_forget(path) # under the lock, the disk's copy
|
|
108
186
|
data = read_json(path)
|
|
109
187
|
before = json.dumps(data, sort_keys=True)
|
|
110
188
|
result = change(data)
|
|
@@ -135,6 +213,29 @@ def _selftest() -> None:
|
|
|
135
213
|
assert data == {"t": 200, "p": 150}, data
|
|
136
214
|
assert update(f, lambda d: "same") == "same" # no change, no write, result passed back
|
|
137
215
|
assert not [p for p in f.parent.iterdir() if p.suffix == ".part"], "temp file left behind"
|
|
216
|
+
# A snapshot reads each file once; its own writes, and leaving it, read the disk again.
|
|
217
|
+
g = pathlib.Path(tmp) / "g.json"
|
|
218
|
+
with snapshot():
|
|
219
|
+
assert read_json(g) == {} # missing stays missing
|
|
220
|
+
g.write_text('{"a": 1}', encoding="utf-8")
|
|
221
|
+
assert read_json(g) == {}
|
|
222
|
+
write_atomic(g, '{"a": 2}')
|
|
223
|
+
assert read_json(g) == {"a": 2}
|
|
224
|
+
g.write_text('{"a": 3}', encoding="utf-8")
|
|
225
|
+
read_json(g)["a"] = 9 # changing what it parsed changes nothing
|
|
226
|
+
assert read_json(g) == {"a": 2}
|
|
227
|
+
assert read_json(g) == {"a": 3}
|
|
228
|
+
# A log read in pieces: only what was added, a half-written line waits,
|
|
229
|
+
# and a rewrite starts over.
|
|
230
|
+
log, mark = pathlib.Path(tmp) / "l.jsonl", {}
|
|
231
|
+
assert appended(log, mark) == (True, [])
|
|
232
|
+
log.write_bytes(b"a\nb\nc")
|
|
233
|
+
assert appended(log, mark) == (True, ["a", "b"])
|
|
234
|
+
with log.open("ab") as fh:
|
|
235
|
+
fh.write(b"\nd\n")
|
|
236
|
+
assert appended(log, mark) == (False, ["c", "d"]) and appended(log, mark) == (False, [])
|
|
237
|
+
log.write_bytes(b"x\ny\nz\nw\n") # longer, but not what was there
|
|
238
|
+
assert appended(log, mark) == (True, ["x", "y", "z", "w"])
|
|
138
239
|
print("ok")
|
|
139
240
|
|
|
140
241
|
|
package/metrics/offload.py
CHANGED
|
@@ -29,34 +29,51 @@ import collections
|
|
|
29
29
|
import datetime as dt
|
|
30
30
|
import json
|
|
31
31
|
import pathlib
|
|
32
|
+
import threading
|
|
32
33
|
|
|
33
34
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
34
35
|
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
35
36
|
LOG = DATA / "metrics" / "offloads.jsonl"
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
_lock = threading.Lock()
|
|
40
|
+
_seen: dict = {"mark": {}, "renames": None, "out": [], "by_call": {}}
|
|
41
|
+
|
|
42
|
+
|
|
38
43
|
def entries() -> list[dict]:
|
|
39
44
|
"""Every offload, oldest first. An entry with a `call` id (written by the
|
|
40
45
|
bench gateway) is superseded by a later one with the same id: the gateway
|
|
41
|
-
records each seat unjudged as it answers, and the verdict comes after.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
records each seat unjudged as it answers, and the verdict comes after.
|
|
47
|
+
|
|
48
|
+
Only the lines added since the last call are read (all of them again
|
|
49
|
+
after a rename): one dashboard render asked seven times, a log of 44,000
|
|
50
|
+
lines took most of a second each, and the gateway adds to it on every
|
|
51
|
+
call. A new list each call; the entries in it are shared, so never change
|
|
52
|
+
one."""
|
|
53
|
+
import aliases
|
|
54
|
+
import locked
|
|
55
|
+
with _lock:
|
|
56
|
+
s = _seen
|
|
57
|
+
if s["renames"] is not aliases.history(): # a rename re-reads the history as a new dict
|
|
58
|
+
s.update(mark={}, renames=aliases.history())
|
|
59
|
+
fresh, lines = locked.appended(LOG, s["mark"])
|
|
60
|
+
if fresh:
|
|
61
|
+
s.update(out=[], by_call={})
|
|
62
|
+
out, by_call = s["out"], s["by_call"]
|
|
63
|
+
for line in lines:
|
|
64
|
+
if line.strip():
|
|
65
|
+
try:
|
|
66
|
+
e = json.loads(line)
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
e["model"] = aliases.current(e.get("model", ""), s["renames"]) # a renamed model keeps its record
|
|
70
|
+
if e.get("call") in by_call:
|
|
71
|
+
out[by_call[e["call"]]] = e
|
|
72
|
+
continue
|
|
73
|
+
if e.get("call"):
|
|
74
|
+
by_call[e["call"]] = len(out)
|
|
75
|
+
out.append(e)
|
|
76
|
+
return list(out)
|
|
60
77
|
|
|
61
78
|
|
|
62
79
|
def add(model: str, task: str, useful: bool | None, note: str, call: str | None = None,
|
package/metrics/route.py
CHANGED
|
@@ -101,8 +101,9 @@ from aliases import current as current_alias # noqa: E402
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
def _json(path: pathlib.Path) -> dict:
|
|
104
|
+
import locked
|
|
104
105
|
try:
|
|
105
|
-
return json.loads(
|
|
106
|
+
return json.loads(locked.read_text(path)) # inside a render, from its snapshot
|
|
106
107
|
except (OSError, ValueError):
|
|
107
108
|
return {}
|
|
108
109
|
|
|
@@ -135,7 +136,14 @@ def full_tag_of(alias: str) -> str:
|
|
|
135
136
|
name, else "?": a hub fronts many connections, and an untagged model
|
|
136
137
|
behind it (Open WebUI stopped listing it) is not on a connection called
|
|
137
138
|
`openwebui` with every other untagged one.
|
|
139
|
+
|
|
140
|
+
Worked out once per alias in a dashboard render, which asked 2,600 times.
|
|
138
141
|
"""
|
|
142
|
+
import locked
|
|
143
|
+
return locked.once(("full_tag_of", alias), lambda: _full_tag_of(alias))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _full_tag_of(alias: str) -> str:
|
|
139
147
|
from run import _registered_names
|
|
140
148
|
import endpoints
|
|
141
149
|
# Tags are keyed by the served name. A decision model is not in the llm
|
package/metrics/scheduler.py
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
RELEASE-PLAN.md Phase 3. The checks used to exist only as Windows scheduled
|
|
7
7
|
tasks (scripts/schedule.ps1), which macOS, Linux and Docker do not have. serve.py
|
|
8
8
|
starts this in a background thread; the jobs below are the same seven, at the
|
|
9
|
-
same times, with the same arguments (schedule.ps1 explains each choice)
|
|
9
|
+
same times, with the same arguments (schedule.ps1 explains each choice), and
|
|
10
|
+
since 2026-11-08 the decision benchmark, which has no Windows task.
|
|
10
11
|
|
|
11
12
|
MODE, from config/scheduler.json:
|
|
12
13
|
absent / "off" nothing runs. A fresh install spends no model calls until
|
|
@@ -55,6 +56,11 @@ JOBS = {
|
|
|
55
56
|
"perf": (["perf/run.py", "-n", "3", "--timeout", "90", "--client", "builtin"], [(0, "07:00")]),
|
|
56
57
|
"capabilities": (["metrics/probe_caps.py", "--gap", "30"], [(6, "07:00")]),
|
|
57
58
|
"suite": (["suite/run.py", "--next"], [(None, t) for t in ("03:00", "09:00", "15:00", "21:00")]),
|
|
59
|
+
# Not in schedule.ps1 (2026-11-08). One question per model per set, 30 s
|
|
60
|
+
# apart round-robin: most decision models are OpenRouter `:free`, whose
|
|
61
|
+
# daily budget the suite and the probe share (metrics/decide.py).
|
|
62
|
+
"decisions": (["metrics/decide.py", "bench", "--per", "1", "--gap", "30", "--new-errors", "3"],
|
|
63
|
+
[(None, "04:30")]),
|
|
58
64
|
}
|
|
59
65
|
# Each mode as the Settings page names and explains it (the docstring above).
|
|
60
66
|
MODES = {"off": ("Off", "Nothing runs. A fresh install spends no model calls until someone opts in."),
|
|
@@ -77,6 +83,9 @@ ABOUT = {
|
|
|
77
83
|
" per model, spaced out.",
|
|
78
84
|
"suite": "Scores one paid model on the review suite every six hours: up to six tasks it has not"
|
|
79
85
|
" answered yet. These scores are what the router ranks by.",
|
|
86
|
+
"decisions": "Measures the decision models: one labelled question per model on each set (error classes,"
|
|
87
|
+
" collections, verdicts), 30 s apart, stopping a model at its first failure; and asks"
|
|
88
|
+
" about up to three new unrecognised error shapes. The gateway's picks ask the best one.",
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
|
|
@@ -213,6 +222,7 @@ def _selftest() -> None:
|
|
|
213
222
|
assert next_due(JOBS["perf"][1], mon) == dt.datetime(2026, 10, 19, 7, 0)
|
|
214
223
|
assert next_due(JOBS["perf"][1], mon.replace(hour=8)) == dt.datetime(2026, 10, 26, 7, 0)
|
|
215
224
|
assert next_due(JOBS["capabilities"][1], mon) == dt.datetime(2026, 10, 25, 7, 0)
|
|
225
|
+
assert next_due(JOBS["decisions"][1], mon) == dt.datetime(2026, 10, 19, 4, 30) and set(ABOUT) == set(JOBS)
|
|
216
226
|
conf = {"mode": "log-only", "jobs": {n: n == "availability" for n in JOBS}}
|
|
217
227
|
state: dict = {}
|
|
218
228
|
global _log
|