@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/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",
|
package/scripts/auto_retire.py
CHANGED
|
@@ -49,8 +49,10 @@ from __future__ import annotations
|
|
|
49
49
|
import csv
|
|
50
50
|
import datetime as dt
|
|
51
51
|
import json
|
|
52
|
+
import os
|
|
52
53
|
import pathlib
|
|
53
54
|
import sys
|
|
55
|
+
import threading
|
|
54
56
|
|
|
55
57
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
56
58
|
sys.path.insert(0, str(ROOT / "metrics"))
|
|
@@ -201,11 +203,23 @@ def _current(alias: str, history: dict[str, str]) -> str:
|
|
|
201
203
|
return alias
|
|
202
204
|
|
|
203
205
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
# evidence() runs on every dashboard render, and re-read 13 MB of offloads and
|
|
207
|
+
# 1,900 saved answers each time: 1.2 s on the owner's data. Each part is read
|
|
208
|
+
# again only when its file changes.
|
|
209
|
+
_perf: tuple = (None, []) # ((alias history, history.csv's stat), its rows)
|
|
210
|
+
_outputs: dict[str, tuple] = {} # saved answer's path -> ((mtime, size), day it answered or None)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _stat(path: pathlib.Path) -> tuple:
|
|
214
|
+
try:
|
|
215
|
+
st = path.stat()
|
|
216
|
+
except OSError:
|
|
217
|
+
return (str(path),)
|
|
218
|
+
return str(path), st.st_mtime_ns, st.st_size
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _perf_rows(path: pathlib.Path, history: dict[str, str]) -> list[dict]:
|
|
207
222
|
rows = []
|
|
208
|
-
path = DATA / "perf" / "history.csv"
|
|
209
223
|
if path.exists():
|
|
210
224
|
with path.open(encoding="utf-8", newline="") as fh:
|
|
211
225
|
for r in csv.DictReader(fh):
|
|
@@ -215,28 +229,74 @@ def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
|
|
|
215
229
|
"failure": r.get("failure") or "",
|
|
216
230
|
"detail": r.get("detail") or ""})
|
|
217
231
|
rows.sort(key=lambda r: r["date"])
|
|
232
|
+
return rows
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
_useful_lock = threading.Lock()
|
|
236
|
+
_useful_seen: dict = {"mark": {}, "history": None, "days": {}}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _useful(path: pathlib.Path, history: dict[str, str]) -> dict[str, dt.date]:
|
|
240
|
+
"""alias -> the last day a useful offload says it answered. Only the lines
|
|
241
|
+
added since the last call are read (the gateway adds some on every call),
|
|
242
|
+
all of them again after a rename."""
|
|
243
|
+
with _useful_lock:
|
|
244
|
+
s = _useful_seen
|
|
245
|
+
if s["history"] != history:
|
|
246
|
+
s.update(mark={}, history=history)
|
|
247
|
+
fresh, lines = locked.appended(path, s["mark"])
|
|
248
|
+
if fresh:
|
|
249
|
+
s["days"] = {}
|
|
250
|
+
days = s["days"]
|
|
251
|
+
for line in lines:
|
|
252
|
+
try:
|
|
253
|
+
o = json.loads(line)
|
|
254
|
+
except ValueError:
|
|
255
|
+
continue
|
|
256
|
+
if o.get("useful"):
|
|
257
|
+
alias, day = _current(o["model"], history), dt.date.fromisoformat(o["date"])
|
|
258
|
+
days[alias] = max(days.get(alias, day), day)
|
|
259
|
+
return dict(days)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _saved_answers():
|
|
263
|
+
"""(model, day) for each saved answer that was a real answer. The listing
|
|
264
|
+
brings each file's stat (free on Windows); a file is read again only when
|
|
265
|
+
it changes."""
|
|
266
|
+
from run import outcome
|
|
267
|
+
for folder in DATA.glob("suite/tasks/*/output"): # the saved answers are the install's data
|
|
268
|
+
try:
|
|
269
|
+
listing = list(os.scandir(folder))
|
|
270
|
+
except OSError:
|
|
271
|
+
continue
|
|
272
|
+
for e in listing:
|
|
273
|
+
if not e.name.endswith(".md"):
|
|
274
|
+
continue
|
|
275
|
+
st = e.stat()
|
|
276
|
+
hit = _outputs.get(e.path)
|
|
277
|
+
if hit is None or hit[0] != (st.st_mtime_ns, st.st_size):
|
|
278
|
+
text = pathlib.Path(e.path).read_text(encoding="utf-8", errors="replace")
|
|
279
|
+
hit = _outputs[e.path] = ((st.st_mtime_ns, st.st_size),
|
|
280
|
+
dt.date.fromtimestamp(st.st_mtime) if outcome(text) is None else None)
|
|
281
|
+
if hit[1]:
|
|
282
|
+
yield e.name[:-3], hit[1]
|
|
218
283
|
|
|
219
|
-
answers: dict[str, dt.date] = {}
|
|
220
284
|
|
|
221
|
-
|
|
285
|
+
def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
|
|
286
|
+
"""(perf rows, last non-perf answer per alias, restore date per alias).
|
|
287
|
+
The rows are shared between calls: read them, never change them."""
|
|
288
|
+
global _perf
|
|
289
|
+
history = locked.read_json(DATA / "config" / "alias-history.json") # {} until a rename
|
|
290
|
+
path = DATA / "perf" / "history.csv"
|
|
291
|
+
if _perf[0] != (key := (history, _stat(path))):
|
|
292
|
+
_perf = (key, _perf_rows(path, history))
|
|
293
|
+
rows = _perf[1]
|
|
294
|
+
offloads = DATA / "metrics" / "offloads.jsonl"
|
|
295
|
+
answers = _useful(offloads, history)
|
|
296
|
+
for alias, day in _saved_answers():
|
|
222
297
|
alias = _current(alias, history)
|
|
223
298
|
answers[alias] = max(answers.get(alias, day), day)
|
|
224
299
|
|
|
225
|
-
offloads = DATA / "metrics" / "offloads.jsonl"
|
|
226
|
-
for line in (offloads.read_text(encoding="utf-8").splitlines()
|
|
227
|
-
if offloads.exists() else []):
|
|
228
|
-
try:
|
|
229
|
-
o = json.loads(line)
|
|
230
|
-
except ValueError:
|
|
231
|
-
continue
|
|
232
|
-
if o.get("useful"):
|
|
233
|
-
saw(o["model"], dt.date.fromisoformat(o["date"]))
|
|
234
|
-
|
|
235
|
-
from run import outcome
|
|
236
|
-
for out in DATA.glob("suite/tasks/*/output/*.md"): # the saved answers are the install's data
|
|
237
|
-
if outcome(out.read_text(encoding="utf-8", errors="replace")) is None:
|
|
238
|
-
saw(out.stem, dt.date.fromtimestamp(out.stat().st_mtime))
|
|
239
|
-
|
|
240
300
|
restored: dict[str, dt.date] = {}
|
|
241
301
|
changes = DATA / "config" / "changes.jsonl"
|
|
242
302
|
for line in (changes.read_text(encoding="utf-8").splitlines()
|
|
@@ -403,6 +463,21 @@ def _selfcheck() -> None:
|
|
|
403
463
|
refused = [row("pk", n, "signin") for n in (9, 5, 0)]
|
|
404
464
|
p = plan(d, {"pk": "v/pk"}, {"v/pk"}, {}, refused, {}, {}, {"v/pk": "v"})
|
|
405
465
|
assert not p["retire"] and not p["confirm"] and not p["pending"], p
|
|
466
|
+
# evidence() reads a file again when it changes, and only then
|
|
467
|
+
global DATA
|
|
468
|
+
import tempfile
|
|
469
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
470
|
+
DATA = pathlib.Path(tmp)
|
|
471
|
+
out = DATA / "suite" / "tasks" / "t" / "output" / "m.md"
|
|
472
|
+
out.parent.mkdir(parents=True)
|
|
473
|
+
out.write_text("a real answer, long enough to count as one: " * 2, encoding="utf-8")
|
|
474
|
+
log = DATA / "metrics" / "offloads.jsonl"
|
|
475
|
+
log.parent.mkdir()
|
|
476
|
+
log.write_text('{"model": "o", "date": "2026-10-01", "useful": true}\n', encoding="utf-8")
|
|
477
|
+
assert set(evidence()[1]) == {"m", "o"}
|
|
478
|
+
out.write_text("<<timed out>>", encoding="utf-8")
|
|
479
|
+
log.write_text("", encoding="utf-8")
|
|
480
|
+
assert evidence()[1] == {}
|
|
406
481
|
print("auto_retire: rules hold")
|
|
407
482
|
|
|
408
483
|
|
package/scripts/onboard.py
CHANGED
|
@@ -21,7 +21,9 @@ WHAT IT DOES, AND WHY IN THIS ORDER
|
|
|
21
21
|
3. **Register and sync**, then, with an Open WebUI hub, refresh the provider
|
|
22
22
|
tags so the connection behind it is known — which is what the router uses
|
|
23
23
|
to keep a panel independent. Without one the endpoint is the connection.
|
|
24
|
-
Then refresh the catalog, so its details (context, tools, vision) show now
|
|
24
|
+
Then refresh the catalog, so its details (context, tools, vision) show now,
|
|
25
|
+
and ask a decision model which role suits it from them: kept as a
|
|
26
|
+
suggestion with its confidence (metrics/decide.py suggest_role), never set.
|
|
25
27
|
4. **Probe availability** before spending anything: two prompt sizes, two
|
|
26
28
|
samples. A model that cannot answer "ready" will not answer a 46 KB task,
|
|
27
29
|
and finding that out here costs four calls instead of six large ones.
|
|
@@ -185,6 +187,17 @@ def makes_images(alias: str, served_name: str) -> bool:
|
|
|
185
187
|
return "image" in out if out else bool(IMAGE_NAMES.search(served_name))
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
def role_facts(alias: str, served_name: str, connection: str) -> str:
|
|
191
|
+
"""What a decision model is told when asked for a new chat model's role:
|
|
192
|
+
its name, connection and catalog facts, the description last."""
|
|
193
|
+
import catalog
|
|
194
|
+
rec = catalog.info(alias)
|
|
195
|
+
facts = [f"{k.replace('_', ' ')}: {', '.join(map(str, v)) if isinstance(v, list) else v}"
|
|
196
|
+
for k in catalog.FIELDS if k != "description" and (v := rec.get(k)) not in (None, "", [])]
|
|
197
|
+
return "\n".join([f"model: {served_name}", f"connection: {connection}", *facts,
|
|
198
|
+
f"description: {rec.get('description') or 'none known'}"])
|
|
199
|
+
|
|
200
|
+
|
|
188
201
|
def run(*args: str, timeout: int = 3600) -> subprocess.CompletedProcess:
|
|
189
202
|
return subprocess.run([str(PY), *args], cwd=str(ROOT), timeout=timeout,
|
|
190
203
|
capture_output=True, text=True, encoding="utf-8",
|
|
@@ -305,7 +318,13 @@ def main() -> None:
|
|
|
305
318
|
lambda roles: roles.setdefault("models", {}).__setitem__(alias, "vision"),
|
|
306
319
|
ensure_ascii=False)
|
|
307
320
|
print(" role vision: a seat only for requests that carry images")
|
|
308
|
-
|
|
321
|
+
# The llm CLI's copy of the registry, only while it is the client: the
|
|
322
|
+
# built-in client reads the data folder's, and choosing the llm CLI on
|
|
323
|
+
# Settings syncs it whole (serve.save_client).
|
|
324
|
+
import client as model_client
|
|
325
|
+
synced = model_client.chosen()[0] == "llm"
|
|
326
|
+
if synced:
|
|
327
|
+
run("scripts/sync_config.py", timeout=300)
|
|
309
328
|
# Open WebUI's tags name the connection behind the hub; without a hub the
|
|
310
329
|
# endpoint is the connection, and route.connection_of needs no tags.
|
|
311
330
|
if endpoints.find_hub("openwebui"):
|
|
@@ -319,7 +338,8 @@ def main() -> None:
|
|
|
319
338
|
catalog = run("metrics/catalog.py", timeout=300)
|
|
320
339
|
details = ("details from the catalog" if catalog.returncode == 0
|
|
321
340
|
else f"catalog refresh failed (exit {catalog.returncode}); details at its next run")
|
|
322
|
-
print(f" [3/5] synced
|
|
341
|
+
print(f" [3/5] {'synced to the llm CLI' if synced else 'the llm CLI not synced (the built-in client is in use)'};"
|
|
342
|
+
f" connection is {connection}; {details}")
|
|
323
343
|
|
|
324
344
|
# An image generator: role image, which no chat shape admits, so it never
|
|
325
345
|
# fills a seat or a collection; a chat probe and the suite would only
|
|
@@ -341,6 +361,16 @@ def main() -> None:
|
|
|
341
361
|
" never a seat, not probed, no suite.\n")
|
|
342
362
|
return
|
|
343
363
|
|
|
364
|
+
# A decision model's suggestion for its role, from what the catalog knows:
|
|
365
|
+
# kept for the model popup (metrics/decided.json), never set from here.
|
|
366
|
+
try:
|
|
367
|
+
hint = decide.suggest_role(alias, role_facts(alias, name, connection))
|
|
368
|
+
print(f" role suggested by {hint['by']}: {hint['role']} at {hint['confidence']:.2f}"
|
|
369
|
+
" (a suggestion; the role is yours to set in its model popup)" if "role" in hint
|
|
370
|
+
else f" no role suggestion: {hint['error']}")
|
|
371
|
+
except Exception as exc: # noqa: BLE001 -- a suggestion never stops an onboarding
|
|
372
|
+
print(f" no role suggestion: {type(exc).__name__}: {exc}")
|
|
373
|
+
|
|
344
374
|
# 4. availability
|
|
345
375
|
print(" [4/5] probing availability (4 calls)...")
|
|
346
376
|
since = dt.datetime.now().isoformat(timespec="seconds")
|
|
@@ -414,6 +444,17 @@ def _selftest() -> None:
|
|
|
414
444
|
# The name fallback, for a model the catalog says nothing about.
|
|
415
445
|
assert all(IMAGE_NAMES.search(s) for s in ("gpt-image-2.5-flare", "openai/dall-e-3", "black-forest-labs/flux.2-pro"))
|
|
416
446
|
assert not any(IMAGE_NAMES.search(s) for s in ("gpt-5.2", "reflux-7b", "gemini-3-pro"))
|
|
447
|
+
# What the role suggestion is told: the catalog's facts, the description last.
|
|
448
|
+
import catalog
|
|
449
|
+
real = catalog.info
|
|
450
|
+
catalog.info = lambda a: {"context_window": 131072, "vision": False, "input_modalities": ["text"],
|
|
451
|
+
"tools": None, "description": "A coding model."}
|
|
452
|
+
try:
|
|
453
|
+
facts = role_facts("m1", "vendor/m1", "groq").splitlines()
|
|
454
|
+
finally:
|
|
455
|
+
catalog.info = real
|
|
456
|
+
assert facts == ["model: vendor/m1", "connection: groq", "context window: 131072", "vision: False",
|
|
457
|
+
"input modalities: text", "description: A coding model."], facts
|
|
417
458
|
print("ok")
|
|
418
459
|
|
|
419
460
|
|