@wendellresearch-ai/benchwarmers 0.5.5 → 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/AGENTS.md +4 -1
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +59 -12
- package/PRIVACY.md +25 -6
- package/README.md +62 -15
- package/SECURITY.md +20 -9
- package/USAGE.md +87 -14
- package/VERSION +1 -1
- package/cli.py +12 -1
- package/gateway.py +122 -91
- package/mcp_server.py +2 -1
- package/metrics/alerts.py +25 -0
- package/metrics/bench_collections.py +58 -9
- package/metrics/catalog.py +54 -9
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/discover.py +191 -11
- 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/owui.py +26 -0
- package/metrics/route.py +19 -4
- package/metrics/scheduler.py +11 -1
- package/metrics/sources.py +15 -2
- 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 +100 -1
- package/scripts/test_serve_edit.py +327 -4
- package/serve.py +1746 -170
- 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 +940 -207
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
|
|
@@ -273,7 +281,7 @@ def suite_recall() -> dict[str, tuple[int, int, int]]:
|
|
|
273
281
|
def panel(shape: str | None = None, large: bool = True,
|
|
274
282
|
nominated: list[str] | None = None,
|
|
275
283
|
members: list[str] | None = None, explorers: int = 2,
|
|
276
|
-
avoid: set[str] | None = None) -> dict:
|
|
284
|
+
avoid: set[str] | None = None, stand_ins: int = 0) -> dict:
|
|
277
285
|
"""`large=True` means the task is a big one — most of ours are.
|
|
278
286
|
|
|
279
287
|
`members` is a bench-gateway COLLECTION: models the caller always runs. With
|
|
@@ -282,7 +290,8 @@ def panel(shape: str | None = None, large: bool = True,
|
|
|
282
290
|
never with a member or a member's family. `avoid` keeps models out of
|
|
283
291
|
those automatic picks (the gateway passes the ones on a retiring alert, so
|
|
284
292
|
a seat is not spent on a model already known to be failing); a nomination
|
|
285
|
-
still seats one.
|
|
293
|
+
still seats one. `stand_ins` more are picked the same way, for members out
|
|
294
|
+
of quota (the gateway); fewer come back if the pool runs out.
|
|
286
295
|
|
|
287
296
|
`nominated` names models the operator wants on the panel today, and they
|
|
288
297
|
take EXPLORER seats — never the primary or the second.
|
|
@@ -469,6 +478,12 @@ def panel(shape: str | None = None, large: bool = True,
|
|
|
469
478
|
|
|
470
479
|
explorer, explorer2 = seats[0], seats[1]
|
|
471
480
|
|
|
481
|
+
standing_in = []
|
|
482
|
+
while len(standing_in) < stand_ins and (pick := stalest()):
|
|
483
|
+
chosen.add(pick["model"])
|
|
484
|
+
seated_families.add(pick["family"])
|
|
485
|
+
standing_in.append(pick)
|
|
486
|
+
|
|
472
487
|
# RESERVE: the fallback when the panel meets a rate limit.
|
|
473
488
|
#
|
|
474
489
|
# This replaces the hard rule that seats must sit on different
|
|
@@ -489,7 +504,7 @@ def panel(shape: str | None = None, large: bool = True,
|
|
|
489
504
|
key=lambda r: (r["found"], paid(r)), default=None)
|
|
490
505
|
|
|
491
506
|
return {"primary": primary, "second": second, "explorer": explorer,
|
|
492
|
-
"explorer2": explorer2, "reserve": reserve,
|
|
507
|
+
"explorer2": explorer2, "reserve": reserve, "stand_ins": standing_in,
|
|
493
508
|
"considered": len(rows),
|
|
494
509
|
"untried": sum(1 for r in rows if not r["offloads"]),
|
|
495
510
|
"contenders": len(contenders),
|
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
|
package/metrics/sources.py
CHANGED
|
@@ -93,17 +93,30 @@ FREELLMAPI_TO_MODELSDEV = {
|
|
|
93
93
|
def fetch(name: str, force: bool = False) -> dict:
|
|
94
94
|
"""A feed, from the cache when it is under a day old."""
|
|
95
95
|
path = CACHE / f"{name}.json"
|
|
96
|
-
|
|
96
|
+
age = age_s(name)
|
|
97
|
+
if not force and age is not None and age < MAX_AGE_S:
|
|
97
98
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
98
99
|
req = urllib.request.Request(FEEDS[name], headers={"User-Agent": "benchwarmers"})
|
|
99
100
|
with urllib.request.urlopen(req, timeout=90) as r:
|
|
100
101
|
data = (json.load(r) if not FEEDS[name].endswith(".xml") else
|
|
101
102
|
{"slugs": sorted(set(re.findall(r"/model/([^<\"/]+)<", r.read().decode("utf-8"))))})
|
|
103
|
+
store(name, data)
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def store(name: str, data: dict) -> None:
|
|
108
|
+
"""Write one cached list whole, never half."""
|
|
102
109
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
path = CACHE / f"{name}.json"
|
|
103
111
|
tmp = path.with_suffix(".part")
|
|
104
112
|
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
105
113
|
tmp.replace(path)
|
|
106
|
-
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def age_s(name: str) -> float | None:
|
|
117
|
+
"""Seconds since a cached list was written; None if it never was."""
|
|
118
|
+
path = CACHE / f"{name}.json"
|
|
119
|
+
return time.time() - path.stat().st_mtime if path.exists() else None
|
|
107
120
|
|
|
108
121
|
|
|
109
122
|
def cached(name: str) -> dict:
|
package/metrics/test_decide.py
CHANGED
|
@@ -1,14 +1,87 @@
|
|
|
1
|
-
"""python metrics/test_decide.py —
|
|
1
|
+
"""python metrics/test_decide.py — the decision client and its log, the benchmark's
|
|
2
|
+
sets, measures and pass, who is asked, the error-shape cache and the role
|
|
3
|
+
suggestion. No network: urlopen is a stub. BENCHWARMERS_HOME is a temp folder,
|
|
4
|
+
set before anything is imported."""
|
|
2
5
|
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import shutil
|
|
3
10
|
import sys
|
|
11
|
+
import tempfile
|
|
4
12
|
import urllib.error
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-decide-"))
|
|
15
|
+
for sub in ("config", "metrics", "perf", "runs/gateway", "llm"):
|
|
16
|
+
(TMP / sub).mkdir(parents=True)
|
|
17
|
+
os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCHWARMERS_KEY_HUB="sk-test")
|
|
18
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
19
|
+
sys.path[:0] = [str(HERE), str(HERE.parent / "suite")]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write(path: str, data) -> None:
|
|
23
|
+
text = data if isinstance(data, str) else json.dumps(data)
|
|
24
|
+
(TMP / path).write_text(text, encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def jsonl(path: str, rows: list[dict]) -> None:
|
|
28
|
+
write(path, "".join(json.dumps(r) + "\n" for r in rows))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
write("config/endpoints.json", {"hub": {"base": "http://127.0.0.1:9/api", "key": "hub", "default": True,
|
|
32
|
+
"decisions": "http://127.0.0.1:9/openai/{path}"}})
|
|
33
|
+
# dm2 first: before any result it is the one asked (the first that takes choice, as before),
|
|
34
|
+
# except per request, where its :free budget is spared while dm1 can answer.
|
|
35
|
+
MODELS = {
|
|
36
|
+
"dm2": {"served": "vendor/d2:free", "via": "hub", "question_types": ["noul", "choice", "score"]},
|
|
37
|
+
"dm1": {"served": "d1", "via": "hub", "path": "systemone", "question_types": ["noul", "choice", "score"]},
|
|
38
|
+
"yesno": {"served": "y", "via": "hub", "question_types": ["noul"]},
|
|
39
|
+
"dear": {"served": "dd", "via": "hub", "question_types": ["noul", "choice"]}}
|
|
40
|
+
write("config/decision-models.json", MODELS)
|
|
41
|
+
write("config/model-roles.json", {"models": {"m1": "general"}, "ad_hoc": ["dear"]})
|
|
42
|
+
write("config/collections.json", {
|
|
43
|
+
"core": {"members": ["m1"], "description": "General second opinions.", "tags": ["general"], "shape": "general"},
|
|
44
|
+
"fast": {"members": ["m1"], "description": "", "tags": ["fast"], "shape": "quick"},
|
|
45
|
+
"vision": {"members": ["m1"], "description": "Photos with questions.", "tags": ["vision"]},
|
|
46
|
+
"dear": {"members": ["m1"], "description": "Costly.", "ad_hoc": True}})
|
|
47
|
+
BUSY = "Error: Error code: 503 - The model is temporarily unavailable due to high demand. (Trace ID: 3fa2c9d81b)"
|
|
48
|
+
QUOTA = "Error: Error code: 429 - Rate limit exceeded: free-models-per-day. Add 10 credits"
|
|
49
|
+
ODD = "Error: the gremlins ate it"
|
|
50
|
+
jsonl("runs/gateway/calls.jsonl", [
|
|
51
|
+
{"panel": "p1", "model": "bench/core", "shape": "general", "shape_by": "set on the collection", "system": "",
|
|
52
|
+
"prompt": "Review this function for bugs.",
|
|
53
|
+
"seats": [{"call": "p1.member1", "status": "ok", "answer": "It double-frees on line 3.", "error": ""},
|
|
54
|
+
{"call": "p1.explorer", "status": "error", "answer": "", "error": BUSY},
|
|
55
|
+
{"call": "p1.explorer2", "status": "empty", "answer": "", "error": ""}]},
|
|
56
|
+
{"panel": "p2", "model": "bench/fast", "shape": "quick", "shape_by": "set on the collection", "system": "Be brief.",
|
|
57
|
+
"prompt": "Spam or not: WIN A PRIZE", "seats": [{"call": "p2.member1", "status": "error", "answer": "", "error": QUOTA},
|
|
58
|
+
{"call": "p2.member2", "status": "error", "answer": "", "error": ODD}]},
|
|
59
|
+
{"panel": "p3", "model": "bench/vision", "shape": "vision", "shape_by": "the request carries images",
|
|
60
|
+
"prompt": "What is printed here? [image: data URL, 12 KB]",
|
|
61
|
+
"seats": [{"call": "p3.member1", "status": "ok", "answer": "A cat.", "error": ""}]},
|
|
62
|
+
# Chosen by a decision model: labels nothing.
|
|
63
|
+
{"panel": "p4", "model": "bench/core", "shape": "general", "shape_by": "set on the collection",
|
|
64
|
+
"prompt": "A request the gateway's pick sent here.", "picked": {"collection": "bench/core"}, "seats": []},
|
|
65
|
+
{"panel": "p5", "model": "bench/dear", "shape": "general", "shape_by": "no explorer seats to fill",
|
|
66
|
+
"prompt": "An ad hoc collection's request.", "seats": []},
|
|
67
|
+
# The shape a decision model chose is not the collection's own.
|
|
68
|
+
{"panel": "p6", "model": "bench/core", "shape": "review", "shape_by": "dm2, 0.99", "prompt": "Is this lock safe?",
|
|
69
|
+
"seats": [{"call": "p6.member1", "status": "ok", "answer": "Yes.", "error": ""}]}])
|
|
70
|
+
jsonl("metrics/offloads.jsonl", [
|
|
71
|
+
{"date": "2026-11-08", "model": "m1", "task": "t", "useful": True, "note": "right", "call": "p1.member1"},
|
|
72
|
+
{"date": "2026-11-08", "model": "m1", "task": "t", "useful": False, "note": "a cat? no", "call": "p3.member1"},
|
|
73
|
+
{"date": "2026-11-08", "model": "m1", "task": "t", "useful": False, "note": "it is not", "call": "p6.member1"},
|
|
74
|
+
{"date": "2026-11-08", "model": "m1", "task": "t", "useful": True, "note": "ui test: in-place verdict",
|
|
75
|
+
"call": "p1.explorer"}])
|
|
76
|
+
write("perf/history.csv", "timestamp,model,prompt,answered,error,detail\n2026-11-08T05:00:00,m1,tiny,0,1,<<TIMED OUT>>\n")
|
|
77
|
+
|
|
7
78
|
import decide # noqa: E402
|
|
8
79
|
|
|
80
|
+
# ---- error text, and the type guard -----------------------------------------------
|
|
81
|
+
|
|
9
82
|
|
|
10
|
-
def http_error(body: bytes) -> urllib.error.HTTPError:
|
|
11
|
-
return urllib.error.HTTPError("http://x",
|
|
83
|
+
def http_error(body: bytes, code: int = 400) -> urllib.error.HTTPError:
|
|
84
|
+
return urllib.error.HTTPError("http://x", code, "bad", {}, io.BytesIO(body))
|
|
12
85
|
|
|
13
86
|
|
|
14
87
|
# Every shape providers send must come back as text, never raise.
|
|
@@ -18,12 +91,207 @@ assert "passthrough" in decide.error_text(http_error(b'{"detail": "Direct API pa
|
|
|
18
91
|
assert decide.error_text(http_error(b'{"error": null, "message": "down"}')).startswith("400")
|
|
19
92
|
assert decide.error_text(http_error(b"not json")) == "400"
|
|
20
93
|
|
|
21
|
-
# ask() refuses a type the entry does not list, before any call is made.
|
|
22
|
-
decide.load = lambda: {"respan": {"served": "r", "via": "nowhere", "question_types": ["noul"]}}
|
|
94
|
+
# ask() refuses a type the entry does not list, before any call is made (and logs nothing).
|
|
23
95
|
try:
|
|
24
|
-
decide.ask("
|
|
96
|
+
decide.ask("yesno", "s", {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}})
|
|
25
97
|
except ValueError as exc:
|
|
26
98
|
assert "does not take choice" in str(exc)
|
|
27
99
|
else:
|
|
28
100
|
raise AssertionError("choice should have been refused")
|
|
101
|
+
assert not decide.LOG.exists()
|
|
102
|
+
|
|
103
|
+
# ---- the stub provider: dm1 answers the truth, dm2 is rate limited, the rest say yes / the first option --
|
|
104
|
+
|
|
105
|
+
SEEN: list[dict] = []
|
|
106
|
+
TRUTH: dict[str, object] = {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def urlopen(request, timeout=0):
|
|
110
|
+
body = json.loads(request.data)
|
|
111
|
+
SEEN.append({"url": request.full_url, **body})
|
|
112
|
+
if body["model"] == "vendor/d2:free":
|
|
113
|
+
raise http_error(b'{"error": {"message": "Rate limit exceeded: free-models-per-min"}}', 429)
|
|
114
|
+
name, q = next(iter(body["questions"].items()))
|
|
115
|
+
truth = TRUTH.get(body["state"]) if body["model"] == "d1" else None
|
|
116
|
+
if q["type"] == "noul":
|
|
117
|
+
p = 0.9 if truth in (True, None) else 0.1
|
|
118
|
+
answer = {"type": "noul", "noul": p}
|
|
119
|
+
else:
|
|
120
|
+
options = list(q["criteria"])
|
|
121
|
+
pick = truth if truth in options else options[0]
|
|
122
|
+
probs = {o: (0.9 if o == pick else 0.1 / (len(options) - 1)) for o in options}
|
|
123
|
+
answer = {"type": "choice", "choice": pick, "probabilities": probs, "confidence": 0.85}
|
|
124
|
+
return io.BytesIO(json.dumps({"answers": {name: answer}}).encode())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
decide.urllib.request.urlopen = urlopen
|
|
128
|
+
|
|
129
|
+
# ---- every call is logged, and an error's body survives the log -------------------------------
|
|
130
|
+
|
|
131
|
+
out = decide.ask("dm1", "hello", {"q": decide.PROBES["noul"]}, use="test")
|
|
132
|
+
assert out["answers"]["q"]["noul"] == 0.9 and SEEN[-1]["url"].endswith("/openai/systemone")
|
|
133
|
+
row = decide.log_rows()[-1]
|
|
134
|
+
assert (row["alias"], row["served"], row["use"], row["types"], row["chars"]) == ("dm1", "d1", "test", {"q": "noul"}, 5)
|
|
135
|
+
assert row["answers"]["q"]["noul"] == 0.9 and row["error"] == "" and isinstance(row["seconds"], float), row
|
|
136
|
+
before = len(SEEN)
|
|
137
|
+
try:
|
|
138
|
+
decide.ask("dm2", "hello", {"q": decide.PROBES["noul"]}, use="test", retry=False)
|
|
139
|
+
except urllib.error.HTTPError as exc:
|
|
140
|
+
assert "free-models-per-min" in decide.error_text(exc) # read once by the log, kept for the caller
|
|
141
|
+
else:
|
|
142
|
+
raise AssertionError("dm2 should have failed")
|
|
143
|
+
assert len(SEEN) == before + 1, "retry=False: a 429 is not retried"
|
|
144
|
+
row = decide.log_rows()[-1]
|
|
145
|
+
assert row["alias"] == "dm2" and row["answers"] is None and "429 Rate limit" in row["error"], row
|
|
146
|
+
|
|
147
|
+
# ---- shapes: nothing private, the status kept -------------------------------------------------
|
|
148
|
+
|
|
149
|
+
s = decide.shape("Error code: 429 - key sk-or-v1-abcdef0123456789 for a.b@example.com at"
|
|
150
|
+
" https://x.example/v1?key=secret, trace 3fa2c9d81b, retry in 37 s")
|
|
151
|
+
assert "429" in s and "<email>" in s and "<url>" in s and "retry in N s" in s, s
|
|
152
|
+
assert not any(w in s for w in ("secret", "example.com", "abcdef0123", "3fa2c9d81b")), s
|
|
153
|
+
assert decide.shape("word " * 400).count("…") == 1 # start and end kept
|
|
154
|
+
|
|
155
|
+
# ---- the sets, from the data written above ----------------------------------------------------
|
|
156
|
+
|
|
157
|
+
sets = decide.build_sets()
|
|
158
|
+
labels = {k: sorted(str(i["label"]) for i in v["items"]) for k, v in sets.items()}
|
|
159
|
+
assert labels["errors"] == ["busy", "empty", "quota", "timeout"], labels # the gremlins: unrecognised
|
|
160
|
+
assert labels["collection"] == ["core", "core", "fast", "vision"], labels # not the pick's, not ad hoc
|
|
161
|
+
assert labels["useful"] == ["False", "True"], labels # text only, no ui test
|
|
162
|
+
assert set(sets) == {"errors", "collection", "useful"} # no shape set
|
|
163
|
+
assert set(sets["collection"]["question"]["criteria"]) == {"core", "fast", "vision"}
|
|
164
|
+
assert sets["collection"]["question"]["criteria"]["fast"] == "No description. Tags: fast."
|
|
165
|
+
assert "Be brief.\n\nSpam or not" in next(i["state"] for i in sets["collection"]["items"] if i["label"] == "fast")
|
|
166
|
+
assert next(i for i in sets["useful"]["items"] if i["label"])["state"].startswith("THE REQUEST\n\nReview this")
|
|
167
|
+
assert not any("3fa2c9d81b" in i["state"] for i in sets["errors"]["items"])
|
|
168
|
+
for v in sets.values():
|
|
169
|
+
for i in v["items"]:
|
|
170
|
+
TRUTH[i["state"]] = i["label"]
|
|
171
|
+
|
|
172
|
+
# ---- who is asked, before any result: the first onboarded that takes the type, not ad hoc ------
|
|
173
|
+
|
|
174
|
+
assert decide.best("errors") == "dm2" and decide.best("useful") == "dm2" and decide.best("role") == "dm2"
|
|
175
|
+
# Per request (the gateway's picks, the notice check) the :free dm2 is spared while dm1 takes the question.
|
|
176
|
+
assert decide.best("shape") == "dm1" and decide.best("collection") == "dm1" and decide.best("notice") == "dm1"
|
|
177
|
+
who, why = decide.choose("shape")
|
|
178
|
+
assert who == "dm1" and "the default" in why and ":free models spared" in why and "collection set" in why, why
|
|
179
|
+
write("config/decision-models.json", {"dm2": MODELS["dm2"]})
|
|
180
|
+
assert decide.best("shape") == "dm2" # the only one: the free budget it is
|
|
181
|
+
write("config/decision-models.json", {"dear": {"served": "dd", "via": "hub", "question_types": ["choice"]}})
|
|
182
|
+
assert decide.best("shape") is None # ad hoc only: nobody
|
|
183
|
+
answer, by, why = decide.pick("shape", "x", decide.SHAPES, "test")
|
|
184
|
+
assert (answer, by, why) == ({}, "", "no decision model onboarded")
|
|
185
|
+
write("config/decision-models.json", MODELS)
|
|
186
|
+
|
|
187
|
+
# ---- the measures, by hand ----------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
choice = [
|
|
190
|
+
{"set": "s", "label": "a", "seconds": 1.0, "at": "1",
|
|
191
|
+
"answers": {"s": {"choice": "a", "probabilities": {"a": 0.9, "b": 0.1}, "confidence": 0.8}}},
|
|
192
|
+
{"set": "s", "label": "a", "seconds": 3.0, "at": "2",
|
|
193
|
+
"answers": {"s": {"choice": "b", "probabilities": {"a": 0.3, "b": 0.7}, "confidence": 0.4}}},
|
|
194
|
+
{"set": "s", "label": "a", "seconds": 2.0, "at": "3", "answers": None, "error": QUOTA}]
|
|
195
|
+
m = decide.measure(choice)
|
|
196
|
+
assert (m["asked"], m["answered"], m["correct"], m["accuracy"]) == (3, 2, 1, 0.5), m
|
|
197
|
+
assert (m["brier"], m["log_loss"], m["ece"], m["macro_f1"], m["seconds"]) == (0.5, 0.6547, 0.4, 0.333, 2.0), m
|
|
198
|
+
assert m["confident"] == {"cutoff": 0.6, "coverage": 0.5, "accuracy": 1.0} and m["failures"] == {"quota": 1}, m
|
|
199
|
+
assert [(b["from"], b["n"], b["right"]) for b in m["calibration"]] == [(0.6, 1, 0.0), (0.8, 1, 1.0)], m
|
|
200
|
+
noul = [{"set": "u", "label": True, "answers": {"u": {"noul": 0.8}}},
|
|
201
|
+
{"set": "u", "label": False, "answers": {"u": {"noul": 0.7}}}]
|
|
202
|
+
m = decide.measure(noul)
|
|
203
|
+
assert (m["accuracy"], m["brier"], "macro_f1" in m) == (0.5, 0.265, False), m
|
|
204
|
+
assert decide.measure([{"set": "u", "label": True, "answers": {"u": {"noul": "?"}}}])["failures"] == {"no answer": 1}
|
|
205
|
+
# A null confidence is no confidence: the answer still counts, at the probability it gave its choice.
|
|
206
|
+
m = decide.measure([{"set": "s", "label": "a", "answers": {"s": {"choice": "a", "probabilities": {"a": 0.9, "b": 0.1},
|
|
207
|
+
"confidence": None}}}])
|
|
208
|
+
assert m["answered"] == 1 and m["confident"]["coverage"] == 1.0, m
|
|
209
|
+
# An endpoint gone from the config is a failed call, logged like any other.
|
|
210
|
+
try:
|
|
211
|
+
decide.call("gone", "x", "s", {"q": decide.PROBES["noul"]}, use="test")
|
|
212
|
+
except KeyError:
|
|
213
|
+
pass
|
|
214
|
+
assert decide.log_rows()[-1]["alias"] == "x" and "KeyError" in decide.log_rows()[-1]["error"]
|
|
215
|
+
|
|
216
|
+
# ---- a dry run asks nobody; a pass is round-robin, paced, and stops a model at its first failure --
|
|
217
|
+
|
|
218
|
+
plan = decide.bench(per=1, dry=True, out=lambda *_: None)
|
|
219
|
+
assert set(plan) == {"dm2", "dm1", "yesno"}, plan # never the ad hoc one
|
|
220
|
+
assert [s for s, _ in plan["yesno"]] == ["useful"] and [s for s, _ in plan["dm1"]] == list(decide.SETS), plan
|
|
221
|
+
SEEN.clear()
|
|
222
|
+
slept: list[float] = []
|
|
223
|
+
res = decide.bench(per=1, gap=7, sleep=slept.append, out=lambda *_: None)
|
|
224
|
+
assert [q["model"] for q in SEEN] == ["vendor/d2:free", "d1", "y", "d1", "d1"], [q["model"] for q in SEEN]
|
|
225
|
+
assert slept == [7] * 4, slept
|
|
226
|
+
assert set(res["models"]["dm1"]) == set(decide.SETS) and all(r["correct"] == 1 for r in res["models"]["dm1"].values())
|
|
227
|
+
assert res["models"]["dm2"]["errors"]["failures"] == {"rate": 1}, res["models"]["dm2"]
|
|
228
|
+
assert res["sets"]["errors"]["size"] == 4 and res["min_items"] == decide.MIN_ITEMS, res
|
|
229
|
+
assert json.loads(decide.RESULTS.read_text(encoding="utf-8"))["models"]["yesno"]["useful"]["correct"] == 1
|
|
230
|
+
# One item each is under MIN_ITEMS: nobody is ranked, the defaults stand.
|
|
231
|
+
assert decide.best("errors") == "dm2" and "none has 8 items on the errors set" in decide.choose("errors")[1]
|
|
232
|
+
decide.MIN_ITEMS = 1
|
|
233
|
+
assert decide.best("errors") == "dm1" and decide.best("role") == "dm1" # 1 of 1 beats 0 of 1
|
|
234
|
+
assert decide.choose("errors")[1] == "best of 2 ranked on the errors set: 1 right of 1 asked"
|
|
235
|
+
assert decide.best("shape") == "dm1" and decide.best("useful") == "dm1" # a tie with yesno: config order
|
|
236
|
+
decide.MIN_ITEMS = 8
|
|
237
|
+
bench_rows = [r for r in decide.log_rows() if r.get("use") == "bench"]
|
|
238
|
+
assert len(bench_rows) == 5 and all(r["item"] and "label" in r for r in bench_rows), bench_rows
|
|
239
|
+
# A second pass asks each model the items it was asked longest ago: another collection item now.
|
|
240
|
+
plan2 = decide.bench(per=1, dry=True, out=lambda *_: None)
|
|
241
|
+
assert dict(plan2["dm1"])["collection"] != dict(plan["dm1"])["collection"], (plan, plan2)
|
|
242
|
+
|
|
243
|
+
# ---- the pick asks the best, and logs the use ----------------------------------------------------
|
|
244
|
+
|
|
245
|
+
answer, by, why = decide.pick("shape", "Review this function for bugs.", decide.SHAPES, "pick_shape")
|
|
246
|
+
assert by == "dm1" and not why and answer["choice"] in decide.SHAPES
|
|
247
|
+
assert decide.log_rows()[-1]["use"] == "pick_shape"
|
|
248
|
+
|
|
249
|
+
# ---- unrecognised error shapes: asked once each, cached, read without a call ----------------------
|
|
250
|
+
|
|
251
|
+
import run # noqa: E402
|
|
252
|
+
|
|
253
|
+
decide.MIN_ITEMS = 1 # dm1 ranked first on errors from here
|
|
254
|
+
SEEN.clear()
|
|
255
|
+
TRUTH[decide.shape(ODD)] = "unreachable" # what the stub's dm1 will say
|
|
256
|
+
assert run.uptime_class(ODD) == ("unrecognised", "nothing matched")
|
|
257
|
+
assert decide.classify_errors(limit=3, sleep=slept.append) == 1 and len(SEEN) == 1 # only the gremlins
|
|
258
|
+
assert SEEN[0]["state"] == decide.shape(ODD) and SEEN[0]["model"] == "d1"
|
|
259
|
+
assert decide.cached_error_class(ODD) == ("unreachable", "decided by dm1 at 0.85"), decide.cached_error_class(ODD)
|
|
260
|
+
assert decide.classify_errors(limit=3) == 0 # asked once per shape
|
|
261
|
+
assert decide.cached_error_class("something else entirely") is None
|
|
262
|
+
# uptime_class reads the decision (no call), the benchmark's labels never do.
|
|
263
|
+
assert run.uptime_class(ODD) == ("unreachable", "decided by dm1 at 0.85") and len(SEEN) == 1
|
|
264
|
+
assert run.uptime_class(ODD, decided=False) == ("unrecognised", "nothing matched")
|
|
265
|
+
assert decide.build_sets(["errors"])["errors"]["items"] == sets["errors"]["items"]
|
|
266
|
+
# A decided `empty` is never used: an error is not an answer, and empty counts as up.
|
|
267
|
+
import locked # noqa: E402
|
|
268
|
+
locked.update(decide.DECIDED, lambda d: d["errors"][decide.shape(ODD)].__setitem__("class", "empty"))
|
|
269
|
+
assert run.uptime_class(ODD)[0] == "unrecognised"
|
|
270
|
+
locked.update(decide.DECIDED, lambda d: d["errors"][decide.shape(ODD)].__setitem__("class", "unreachable"))
|
|
271
|
+
|
|
272
|
+
# ---- the provider-notice fallback: asked only for a seat's short reply that looks like one ----------
|
|
273
|
+
|
|
274
|
+
SEEN.clear()
|
|
275
|
+
NOTE = "Your plan's balance is low; top up soon to keep going."
|
|
276
|
+
assert run.provider_notice("ready") is None and run.provider_notice("ready", ask=True) is None
|
|
277
|
+
assert run.provider_notice("The limit here is " + "x " * 200, ask=True) is None # long: an answer
|
|
278
|
+
assert run.provider_notice(NOTE) is None and not SEEN # a page or a score never asks
|
|
279
|
+
assert run.provider_notice("you have run out of credits") == "run out of credits" and not SEEN # the phrases first
|
|
280
|
+
assert run.provider_notice(NOTE, ask=True) == "decided by dm1 at 0.90" and len(SEEN) == 1
|
|
281
|
+
assert SEEN[0]["model"] == "d1" and SEEN[0]["questions"]["notice"]["type"] == "noul" # per request: not :free
|
|
282
|
+
assert decide.log_rows()[-1]["use"] == "notice"
|
|
283
|
+
assert run.provider_notice(NOTE, ask=True) == "decided by dm1 at 0.90" and len(SEEN) == 1 # kept: asked once
|
|
284
|
+
assert run.provider_notice(NOTE) == "decided by dm1 at 0.90" # and read anywhere
|
|
285
|
+
assert run.failure_class(NOTE) == "entitlement"
|
|
286
|
+
|
|
287
|
+
# ---- a role suggestion is kept, never set --------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
hint = decide.suggest_role("m2", "model: vendor/m2\ncontext window: 8192\ndescription: A small coder.")
|
|
290
|
+
assert (hint["role"], hint["confidence"], hint["by"]) == ("general", 0.85, "dm1"), hint # the stub's first option
|
|
291
|
+
assert "decision" not in SEEN[-1]["questions"]["role"]["criteria"], SEEN[-1]
|
|
292
|
+
kept = json.loads(decide.DECIDED.read_text(encoding="utf-8"))
|
|
293
|
+
assert kept["roles"]["m2"]["role"] == hint["role"] and "unreachable" == kept["errors"][decide.shape(ODD)]["class"]
|
|
294
|
+
assert json.loads((TMP / "config" / "model-roles.json").read_text(encoding="utf-8"))["models"] == {"m1": "general"}
|
|
295
|
+
|
|
296
|
+
shutil.rmtree(TMP, ignore_errors=True)
|
|
29
297
|
print("ok")
|
package/metrics/wrong.py
CHANGED
|
@@ -75,13 +75,30 @@ def judge(spec: dict, text: str, invented: list) -> tuple[str | None, bool]:
|
|
|
75
75
|
return None, False
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
def
|
|
78
|
+
def _row() -> dict:
|
|
79
|
+
return {"answers": 0, "wrong": 0, "by": {k: [0, 0] for k in KINDS}, "reviews": 0, "review_false": 0,
|
|
80
|
+
"hunt": dict.fromkeys(HUNT, 0)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def table(scored: dict[str, dict] | None = None) -> dict[str, dict]:
|
|
84
|
+
"""scores(), or the `scored` the caller kept from it, with each model's
|
|
85
|
+
reviews from the offloads added."""
|
|
86
|
+
import copy
|
|
79
87
|
import offload
|
|
88
|
+
out = collections.defaultdict(_row, copy.deepcopy(scores() if scored is None else scored))
|
|
89
|
+
for e in offload.entries():
|
|
90
|
+
if e.get("useful") is not None:
|
|
91
|
+
row = out[e["model"]]
|
|
92
|
+
row["reviews"] += 1
|
|
93
|
+
row["review_false"] += len(e.get("false") or [])
|
|
94
|
+
return dict(out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def scores() -> dict[str, dict]:
|
|
98
|
+
"""Every suite answer judged, by model: the costly part (it reads them all)."""
|
|
80
99
|
import run
|
|
81
100
|
from aliases import current
|
|
82
|
-
out: dict[str, dict] = collections.defaultdict(
|
|
83
|
-
lambda: {"answers": 0, "wrong": 0, "by": {k: [0, 0] for k in KINDS}, "reviews": 0, "review_false": 0,
|
|
84
|
-
"hunt": dict.fromkeys(HUNT, 0)})
|
|
101
|
+
out: dict[str, dict] = collections.defaultdict(_row)
|
|
85
102
|
for path in sorted(TASKS.glob("*/task.json")):
|
|
86
103
|
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
87
104
|
# Reading images has its own record (serve.vision_rows): a trap taken
|
|
@@ -104,11 +121,6 @@ def table() -> dict[str, dict]:
|
|
|
104
121
|
if wrong:
|
|
105
122
|
row["wrong"] += 1
|
|
106
123
|
row["by"][kind][0] += 1
|
|
107
|
-
for e in offload.entries():
|
|
108
|
-
if e.get("useful") is not None:
|
|
109
|
-
row = out[e["model"]]
|
|
110
|
-
row["reviews"] += 1
|
|
111
|
-
row["review_false"] += len(e.get("false") or [])
|
|
112
124
|
return dict(out)
|
|
113
125
|
|
|
114
126
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
|
|
5
5
|
"mcpName": "io.github.WendellResearch-AI/benchwarmers",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"repoqa/*.py",
|
|
50
50
|
"repoqa/*.md",
|
|
51
51
|
"ui/",
|
|
52
|
+
".github/logo.svg",
|
|
53
|
+
".github/logo-dark.svg",
|
|
52
54
|
"examples/",
|
|
53
55
|
"VERSION",
|
|
54
56
|
"NOTICE",
|