@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.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/metrics/pacing.py CHANGED
@@ -28,7 +28,9 @@ WHAT IS COUNTED
28
28
  deadline is dropped, so a killed process cannot hold a slot.
29
29
  - A connection's limits count every model on it (a key's limits are the
30
30
  key's); a model's own or learned limits count that model alone, its own
31
- beating the learned (as everywhere: your values win).
31
+ beating the learned (as everywhere: your values win). Where you set none
32
+ for a connection, the rpm or rpd learn.py worked out from its 429s is kept
33
+ to (config/connection-learned.json), until it expires.
32
34
  - Quota records (metrics/quota.py): what a provider last said was left, less
33
35
  the calls sent since, until its reset.
34
36
 
@@ -77,6 +79,7 @@ def rules(alias: str, connection: str) -> list[dict]:
77
79
  conn = catalog.connection_limits().get(connection) or {}
78
80
  mine = (catalog.overrides().get(alias) or {}).get("limits") or {}
79
81
  seen = (learn.learned().get(alias) or {}).get("limits") or {}
82
+ met = learn.connection_learned().get(connection) or {}
80
83
  out = []
81
84
 
82
85
  def ok(n) -> bool:
@@ -85,6 +88,10 @@ def rules(alias: str, connection: str) -> list[dict]:
85
88
  if ok(n := conn.get(axis)):
86
89
  out.append({"scope": "connection", "axis": axis, "limit": n,
87
90
  "source": f"you, for {connection} (config/connection-limits.json)"})
91
+ elif ok(n := (met.get(axis) or {}).get("value")):
92
+ out.append({"scope": "connection", "axis": axis, "limit": n,
93
+ "source": f"learned {str(met[axis].get('at', ''))[:10]} from 429s on {connection}"
94
+ " (config/connection-learned.json)"})
88
95
  if axis == "rpmo":
89
96
  continue
90
97
  if ok(n := mine.get(axis)):
@@ -512,14 +512,21 @@ def _limit_text(lim: dict) -> str:
512
512
  return ", ".join(f"{v:,} {words[k]}" for k, v in lim.items() if k in words and v)
513
513
 
514
514
 
515
- def caps(host: str, entered: dict, stated: dict, day_rows: list, month_rows, now: float) -> list[dict]:
515
+ def caps(host: str, entered: dict, stated: dict, day_rows: list, month_rows, now: float,
516
+ learned: dict | None = None) -> list[dict]:
516
517
  """The connection's day and month caps as groups of windows, each counted
517
518
  from the bench's ledger since midnight UTC or the month's start (what a
518
- provider's calendar counts): yours, else `stated` ({axis: (limit, source,
519
- detail)}: what its replies say with nothing left given, or NVIDIA's site).
519
+ provider's calendar counts): yours, else `learned` (learn.py's, from this
520
+ connection's 429s), else `stated` ({axis: (limit, source, detail)}: what
521
+ its replies say with nothing left given, or NVIDIA's site).
520
522
  `month_rows` is called only when a month cap needs it."""
521
523
  mine = {k: (v, "your cap", "Set on Endpoints, Rate limits per connection; the bench keeps to it.")
522
524
  for k, v in (entered.get(host) or {}).items() if k in COUNTED and v}
525
+ for axis, ev in (learned or {}).items():
526
+ if axis in COUNTED:
527
+ mine.setdefault(axis, (ev["value"], "learned", f"Worked out {str(ev.get('at'))[:10]} from this connection's"
528
+ " 429s, which named no number; the bench keeps to it until you set your own"
529
+ " on Endpoints, or it has not been met again for 14 days."))
523
530
  for axis, (n, source, detail) in stated.items():
524
531
  mine.setdefault(axis, (n, source, detail + " Shown, not enforced: set it on Endpoints to have the bench"
525
532
  " keep to it."))
@@ -554,7 +561,7 @@ def tiles(now: float | None = None) -> list[dict]:
554
561
  resting, learned = learn.cooldowns(), learn.learned()
555
562
  day_rows = list(ledger.rows(now - 86400))
556
563
  utc_rows = [r for r in day_rows if r["t"] >= _utc_midnight(now)]
557
- entered = catalog.connection_limits()
564
+ entered, met = catalog.connection_limits(), learn.connection_learned()
558
565
  regs = __import__("endpoints").registrations()
559
566
  month: list = [] # this month's ledger, read once if a month cap asks
560
567
 
@@ -589,7 +596,7 @@ def tiles(now: float | None = None) -> list[dict]:
589
596
  t = {"id": tag, "tag": tag, "host": host, "bench": len(aliases), "kind": "models", "groups": [],
590
597
  "pool": [], "notes": [], # a day or month cap is a meter (caps), a minute one is said
591
598
  "conn_limit": _limit_text({k: v for k, v in (entered.get(host) or {}).items() if k in ("rpm", "tpm")}),
592
- "caps": caps(host, entered, stated, day_rows, month_rows, now),
599
+ "caps": caps(host, entered, stated, day_rows, month_rows, now, met.get(host)),
593
600
  "models": _model_rows(aliases, rows, resting, limits),
594
601
  "refresh": how_refreshed(tag), "plan": "", "name": ""}
595
602
  got = live.get(tag) or {}
@@ -743,6 +750,13 @@ def _selftest() -> None:
743
750
  g = caps("router.requesty.ai", {"router.requesty.ai": {"rpd": 10, "rpm": 5}}, {"rpd": said}, day, lambda: [], now)
744
751
  assert g[0]["label"] == "your cap · counted by the bench" and g[0]["windows"] == [
745
752
  {"label": "requests · day", "left": 80, "remaining": 8, "of": 10, "reset_at": _iso(mid + 86400)}], g
753
+ # A learned day cap (learn.py, from 429s) beats a stated one, and yours beats it; a learned rpm is no meter.
754
+ met = {"rpd": {"value": 20, "at": "2026-11-10T09:00:00+00:00"}, "rpm": {"value": 4, "at": ""}}
755
+ g = caps("router.requesty.ai", {}, {"rpd": said}, day, lambda: [], now, met)
756
+ assert len(g) == 1 and g[0]["label"] == "learned · counted by the bench" and "2026-11-10" in g[0]["title"] \
757
+ and g[0]["windows"][0]["of"] == 20 and g[0]["windows"][0]["remaining"] == 18, g
758
+ g = caps("router.requesty.ai", {"router.requesty.ai": {"rpd": 10}}, {}, day, lambda: [], now, met)
759
+ assert len(g) == 1 and g[0]["label"] == "your cap · counted by the bench", g
746
760
  asked = []
747
761
  g = caps("api.cohere.ai", {}, {"rpmo": (1000, "its replies", "")}, day, lambda: asked.append(1) or day, now)
748
762
  assert asked and g[0]["windows"][0]["remaining"] == 999 and g[0]["windows"][0]["label"] == "requests · month", g
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(path.read_text(encoding="utf-8"))
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
@@ -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
@@ -1,14 +1,89 @@
1
- """python metrics/test_decide.py — error parsing and the question-type guard, no network."""
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
- sys.path.insert(0, __file__.rsplit("\\", 1)[0].rsplit("/", 1)[0])
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
+ "cf": {"base": "http://127.0.0.1:9/ai/v1", "key": "hub",
34
+ "decisions": "http://127.0.0.1:9/ai/run/{model}"}})
35
+ # dm2 first: before any result it is the one asked (the first that takes choice, as before),
36
+ # except per request, where its :free budget is spared while dm1 can answer.
37
+ MODELS = {
38
+ "dm2": {"served": "vendor/d2:free", "via": "hub", "question_types": ["noul", "choice", "score"]},
39
+ "dm1": {"served": "d1", "via": "hub", "path": "systemone", "question_types": ["noul", "choice", "score"]},
40
+ "yesno": {"served": "y", "via": "hub", "question_types": ["noul"]},
41
+ "dear": {"served": "dd", "via": "hub", "question_types": ["noul", "choice"]}}
42
+ write("config/decision-models.json", MODELS)
43
+ write("config/model-roles.json", {"models": {"m1": "general"}, "ad_hoc": ["dear"]})
44
+ write("config/collections.json", {
45
+ "core": {"members": ["m1"], "description": "General second opinions.", "tags": ["general"], "shape": "general"},
46
+ "fast": {"members": ["m1"], "description": "", "tags": ["fast"], "shape": "quick"},
47
+ "vision": {"members": ["m1"], "description": "Photos with questions.", "tags": ["vision"]},
48
+ "dear": {"members": ["m1"], "description": "Costly.", "ad_hoc": True}})
49
+ BUSY = "Error: Error code: 503 - The model is temporarily unavailable due to high demand. (Trace ID: 3fa2c9d81b)"
50
+ QUOTA = "Error: Error code: 429 - Rate limit exceeded: free-models-per-day. Add 10 credits"
51
+ ODD = "Error: the gremlins ate it"
52
+ jsonl("runs/gateway/calls.jsonl", [
53
+ {"panel": "p1", "model": "bench/core", "shape": "general", "shape_by": "set on the collection", "system": "",
54
+ "prompt": "Review this function for bugs.",
55
+ "seats": [{"call": "p1.member1", "status": "ok", "answer": "It double-frees on line 3.", "error": ""},
56
+ {"call": "p1.explorer", "status": "error", "answer": "", "error": BUSY},
57
+ {"call": "p1.explorer2", "status": "empty", "answer": "", "error": ""}]},
58
+ {"panel": "p2", "model": "bench/fast", "shape": "quick", "shape_by": "set on the collection", "system": "Be brief.",
59
+ "prompt": "Spam or not: WIN A PRIZE", "seats": [{"call": "p2.member1", "status": "error", "answer": "", "error": QUOTA},
60
+ {"call": "p2.member2", "status": "error", "answer": "", "error": ODD}]},
61
+ {"panel": "p3", "model": "bench/vision", "shape": "vision", "shape_by": "the request carries images",
62
+ "prompt": "What is printed here? [image: data URL, 12 KB]",
63
+ "seats": [{"call": "p3.member1", "status": "ok", "answer": "A cat.", "error": ""}]},
64
+ # Chosen by a decision model: labels nothing.
65
+ {"panel": "p4", "model": "bench/core", "shape": "general", "shape_by": "set on the collection",
66
+ "prompt": "A request the gateway's pick sent here.", "picked": {"collection": "bench/core"}, "seats": []},
67
+ {"panel": "p5", "model": "bench/dear", "shape": "general", "shape_by": "no explorer seats to fill",
68
+ "prompt": "An ad hoc collection's request.", "seats": []},
69
+ # The shape a decision model chose is not the collection's own.
70
+ {"panel": "p6", "model": "bench/core", "shape": "review", "shape_by": "dm2, 0.99", "prompt": "Is this lock safe?",
71
+ "seats": [{"call": "p6.member1", "status": "ok", "answer": "Yes.", "error": ""}]}])
72
+ jsonl("metrics/offloads.jsonl", [
73
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": True, "note": "right", "call": "p1.member1"},
74
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": False, "note": "a cat? no", "call": "p3.member1"},
75
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": False, "note": "it is not", "call": "p6.member1"},
76
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": True, "note": "ui test: in-place verdict",
77
+ "call": "p1.explorer"}])
78
+ write("perf/history.csv", "timestamp,model,prompt,answered,error,detail\n2026-11-08T05:00:00,m1,tiny,0,1,<<TIMED OUT>>\n")
79
+
7
80
  import decide # noqa: E402
8
81
 
82
+ # ---- error text, and the type guard -----------------------------------------------
83
+
9
84
 
10
- def http_error(body: bytes) -> urllib.error.HTTPError:
11
- return urllib.error.HTTPError("http://x", 400, "bad", {}, io.BytesIO(body))
85
+ def http_error(body: bytes, code: int = 400) -> urllib.error.HTTPError:
86
+ return urllib.error.HTTPError("http://x", code, "bad", {}, io.BytesIO(body))
12
87
 
13
88
 
14
89
  # Every shape providers send must come back as text, never raise.
@@ -18,12 +93,229 @@ assert "passthrough" in decide.error_text(http_error(b'{"detail": "Direct API pa
18
93
  assert decide.error_text(http_error(b'{"error": null, "message": "down"}')).startswith("400")
19
94
  assert decide.error_text(http_error(b"not json")) == "400"
20
95
 
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"]}}
96
+ # A chat refusal says "decision model" however the provider words it.
97
+ assert decide.names_decisions("400 is a decisions model and cannot be used with chat") # OpenRouter
98
+ assert decide.names_decisions("400 {'errors': [{'message': \"AiError: Bad input: Error: required properties"
99
+ " at '/' are 'model,state,questions' (72ae)\", 'code': 5006}]}") # Cloudflare
100
+ assert decide.names_decisions("400 Model execution failed (User Input Error): Required value missing: questions.")
101
+ assert not decide.names_decisions("400 messages: Required") # Cloudflare's v1 refusing a decision body
102
+ assert not decide.names_decisions("429 Rate limit exceeded") and not decide.names_decisions("")
103
+
104
+ # ask() refuses a type the entry does not list, before any call is made (and logs nothing).
23
105
  try:
24
- decide.ask("respan", "s", {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}})
106
+ decide.ask("yesno", "s", {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}})
25
107
  except ValueError as exc:
26
108
  assert "does not take choice" in str(exc)
27
109
  else:
28
110
  raise AssertionError("choice should have been refused")
111
+ assert not decide.LOG.exists()
112
+
113
+ # ---- the stub provider: dm1 answers the truth, dm2 is rate limited, the rest say yes / the first option --
114
+
115
+ SEEN: list[dict] = []
116
+ TRUTH: dict[str, object] = {}
117
+
118
+
119
+ def urlopen(request, timeout=0):
120
+ body = json.loads(request.data)
121
+ SEEN.append({"url": request.full_url, **body})
122
+ if body["model"] == "vendor/d2:free":
123
+ raise http_error(b'{"error": {"message": "Rate limit exceeded: free-models-per-min"}}', 429)
124
+ name, q = next(iter(body["questions"].items()))
125
+ truth = TRUTH.get(body["state"]) if body["model"] == "d1" else None
126
+ if q["type"] == "noul":
127
+ p = 0.9 if truth in (True, None) else 0.1
128
+ answer = {"type": "noul", "noul": p}
129
+ else:
130
+ options = list(q["criteria"])
131
+ pick = truth if truth in options else options[0]
132
+ probs = {o: (0.9 if o == pick else 0.1 / (len(options) - 1)) for o in options}
133
+ answer = {"type": "choice", "choice": pick, "probabilities": probs, "confidence": 0.85}
134
+ return io.BytesIO(json.dumps({"answers": {name: answer}}).encode())
135
+
136
+
137
+
138
+
139
+ # Cloudflare: the model in the URL and not the body, the reply in a "result" wrapper.
140
+ def cf_urlopen(request, timeout=0):
141
+ body = json.loads(request.data)
142
+ assert request.full_url == "http://127.0.0.1:9/ai/run/@cf/cloudflare/clef-flash" and "model" not in body, body
143
+ return io.BytesIO(json.dumps({"result": {"model": "clef-flash", "answers": {"probe": {"type": "noul", "noul": 0.98}}},
144
+ "success": True, "errors": []}).encode())
145
+
146
+
147
+ decide.urllib.request.urlopen = cf_urlopen
148
+ assert decide.probe("cf", "@cf/cloudflare/clef-flash") == (None, "decisions")
149
+ assert decide.log_rows()[-1]["answers"]["probe"]["noul"] == 0.98
150
+ decide.LOG.unlink()
151
+ decide.urllib.request.urlopen = urlopen
152
+
153
+ # ---- every call is logged, and an error's body survives the log -------------------------------
154
+
155
+ out = decide.ask("dm1", "hello", {"q": decide.PROBES["noul"]}, use="test")
156
+ assert out["answers"]["q"]["noul"] == 0.9 and SEEN[-1]["url"].endswith("/openai/systemone")
157
+ row = decide.log_rows()[-1]
158
+ assert (row["alias"], row["served"], row["use"], row["types"], row["chars"]) == ("dm1", "d1", "test", {"q": "noul"}, 5)
159
+ assert row["answers"]["q"]["noul"] == 0.9 and row["error"] == "" and isinstance(row["seconds"], float), row
160
+ before = len(SEEN)
161
+ try:
162
+ decide.ask("dm2", "hello", {"q": decide.PROBES["noul"]}, use="test", retry=False)
163
+ except urllib.error.HTTPError as exc:
164
+ assert "free-models-per-min" in decide.error_text(exc) # read once by the log, kept for the caller
165
+ else:
166
+ raise AssertionError("dm2 should have failed")
167
+ assert len(SEEN) == before + 1, "retry=False: a 429 is not retried"
168
+ row = decide.log_rows()[-1]
169
+ assert row["alias"] == "dm2" and row["answers"] is None and "429 Rate limit" in row["error"], row
170
+
171
+ # ---- shapes: nothing private, the status kept -------------------------------------------------
172
+
173
+ s = decide.shape("Error code: 429 - key sk-or-v1-abcdef0123456789 for a.b@example.com at"
174
+ " https://x.example/v1?key=secret, trace 3fa2c9d81b, retry in 37 s")
175
+ assert "429" in s and "<email>" in s and "<url>" in s and "retry in N s" in s, s
176
+ assert not any(w in s for w in ("secret", "example.com", "abcdef0123", "3fa2c9d81b")), s
177
+ assert decide.shape("word " * 400).count("…") == 1 # start and end kept
178
+
179
+ # ---- the sets, from the data written above ----------------------------------------------------
180
+
181
+ sets = decide.build_sets()
182
+ labels = {k: sorted(str(i["label"]) for i in v["items"]) for k, v in sets.items()}
183
+ assert labels["errors"] == ["busy", "empty", "quota", "timeout"], labels # the gremlins: unrecognised
184
+ assert labels["collection"] == ["core", "core", "fast", "vision"], labels # not the pick's, not ad hoc
185
+ assert labels["useful"] == ["False", "True"], labels # text only, no ui test
186
+ assert set(sets) == {"errors", "collection", "useful"} # no shape set
187
+ assert set(sets["collection"]["question"]["criteria"]) == {"core", "fast", "vision"}
188
+ assert sets["collection"]["question"]["criteria"]["fast"] == "No description. Tags: fast."
189
+ assert "Be brief.\n\nSpam or not" in next(i["state"] for i in sets["collection"]["items"] if i["label"] == "fast")
190
+ assert next(i for i in sets["useful"]["items"] if i["label"])["state"].startswith("THE REQUEST\n\nReview this")
191
+ assert not any("3fa2c9d81b" in i["state"] for i in sets["errors"]["items"])
192
+ for v in sets.values():
193
+ for i in v["items"]:
194
+ TRUTH[i["state"]] = i["label"]
195
+
196
+ # ---- who is asked, before any result: the first onboarded that takes the type, not ad hoc ------
197
+
198
+ assert decide.best("errors") == "dm2" and decide.best("useful") == "dm2" and decide.best("role") == "dm2"
199
+ # Per request (the gateway's picks, the notice check) the :free dm2 is spared while dm1 takes the question.
200
+ assert decide.best("shape") == "dm1" and decide.best("collection") == "dm1" and decide.best("notice") == "dm1"
201
+ who, why = decide.choose("shape")
202
+ assert who == "dm1" and "the default" in why and ":free models spared" in why and "collection set" in why, why
203
+ write("config/decision-models.json", {"dm2": MODELS["dm2"]})
204
+ assert decide.best("shape") == "dm2" # the only one: the free budget it is
205
+ write("config/decision-models.json", {"dear": {"served": "dd", "via": "hub", "question_types": ["choice"]}})
206
+ assert decide.best("shape") is None # ad hoc only: nobody
207
+ answer, by, why = decide.pick("shape", "x", decide.SHAPES, "test")
208
+ assert (answer, by, why) == ({}, "", "no decision model onboarded")
209
+ write("config/decision-models.json", MODELS)
210
+
211
+ # ---- the measures, by hand ----------------------------------------------------------------------
212
+
213
+ choice = [
214
+ {"set": "s", "label": "a", "seconds": 1.0, "at": "1",
215
+ "answers": {"s": {"choice": "a", "probabilities": {"a": 0.9, "b": 0.1}, "confidence": 0.8}}},
216
+ {"set": "s", "label": "a", "seconds": 3.0, "at": "2",
217
+ "answers": {"s": {"choice": "b", "probabilities": {"a": 0.3, "b": 0.7}, "confidence": 0.4}}},
218
+ {"set": "s", "label": "a", "seconds": 2.0, "at": "3", "answers": None, "error": QUOTA}]
219
+ m = decide.measure(choice)
220
+ assert (m["asked"], m["answered"], m["correct"], m["accuracy"]) == (3, 2, 1, 0.5), m
221
+ assert (m["brier"], m["log_loss"], m["ece"], m["macro_f1"], m["seconds"]) == (0.5, 0.6547, 0.4, 0.333, 2.0), m
222
+ assert m["confident"] == {"cutoff": 0.6, "coverage": 0.5, "accuracy": 1.0} and m["failures"] == {"quota": 1}, m
223
+ assert [(b["from"], b["n"], b["right"]) for b in m["calibration"]] == [(0.6, 1, 0.0), (0.8, 1, 1.0)], m
224
+ noul = [{"set": "u", "label": True, "answers": {"u": {"noul": 0.8}}},
225
+ {"set": "u", "label": False, "answers": {"u": {"noul": 0.7}}}]
226
+ m = decide.measure(noul)
227
+ assert (m["accuracy"], m["brier"], "macro_f1" in m) == (0.5, 0.265, False), m
228
+ assert decide.measure([{"set": "u", "label": True, "answers": {"u": {"noul": "?"}}}])["failures"] == {"no answer": 1}
229
+ # A null confidence is no confidence: the answer still counts, at the probability it gave its choice.
230
+ m = decide.measure([{"set": "s", "label": "a", "answers": {"s": {"choice": "a", "probabilities": {"a": 0.9, "b": 0.1},
231
+ "confidence": None}}}])
232
+ assert m["answered"] == 1 and m["confident"]["coverage"] == 1.0, m
233
+ # An endpoint gone from the config is a failed call, logged like any other.
234
+ try:
235
+ decide.call("gone", "x", "s", {"q": decide.PROBES["noul"]}, use="test")
236
+ except KeyError:
237
+ pass
238
+ assert decide.log_rows()[-1]["alias"] == "x" and "KeyError" in decide.log_rows()[-1]["error"]
239
+
240
+ # ---- a dry run asks nobody; a pass is round-robin, paced, and stops a model at its first failure --
241
+
242
+ plan = decide.bench(per=1, dry=True, out=lambda *_: None)
243
+ assert set(plan) == {"dm2", "dm1", "yesno"}, plan # never the ad hoc one
244
+ assert [s for s, _ in plan["yesno"]] == ["useful"] and [s for s, _ in plan["dm1"]] == list(decide.SETS), plan
245
+ SEEN.clear()
246
+ slept: list[float] = []
247
+ res = decide.bench(per=1, gap=7, sleep=slept.append, out=lambda *_: None)
248
+ assert [q["model"] for q in SEEN] == ["vendor/d2:free", "d1", "y", "d1", "d1"], [q["model"] for q in SEEN]
249
+ assert slept == [7] * 4, slept
250
+ assert set(res["models"]["dm1"]) == set(decide.SETS) and all(r["correct"] == 1 for r in res["models"]["dm1"].values())
251
+ assert res["models"]["dm2"]["errors"]["failures"] == {"rate": 1}, res["models"]["dm2"]
252
+ assert res["sets"]["errors"]["size"] == 4 and res["min_items"] == decide.MIN_ITEMS, res
253
+ assert json.loads(decide.RESULTS.read_text(encoding="utf-8"))["models"]["yesno"]["useful"]["correct"] == 1
254
+ # One item each is under MIN_ITEMS: nobody is ranked, the defaults stand.
255
+ assert decide.best("errors") == "dm2" and "none has 8 items on the errors set" in decide.choose("errors")[1]
256
+ decide.MIN_ITEMS = 1
257
+ assert decide.best("errors") == "dm1" and decide.best("role") == "dm1" # 1 of 1 beats 0 of 1
258
+ assert decide.choose("errors")[1] == "best of 2 ranked on the errors set: 1 right of 1 asked"
259
+ assert decide.best("shape") == "dm1" and decide.best("useful") == "dm1" # a tie with yesno: config order
260
+ decide.MIN_ITEMS = 8
261
+ bench_rows = [r for r in decide.log_rows() if r.get("use") == "bench"]
262
+ assert len(bench_rows) == 5 and all(r["item"] and "label" in r for r in bench_rows), bench_rows
263
+ # A second pass asks each model the items it was asked longest ago: another collection item now.
264
+ plan2 = decide.bench(per=1, dry=True, out=lambda *_: None)
265
+ assert dict(plan2["dm1"])["collection"] != dict(plan["dm1"])["collection"], (plan, plan2)
266
+
267
+ # ---- the pick asks the best, and logs the use ----------------------------------------------------
268
+
269
+ answer, by, why = decide.pick("shape", "Review this function for bugs.", decide.SHAPES, "pick_shape")
270
+ assert by == "dm1" and not why and answer["choice"] in decide.SHAPES
271
+ assert decide.log_rows()[-1]["use"] == "pick_shape"
272
+
273
+ # ---- unrecognised error shapes: asked once each, cached, read without a call ----------------------
274
+
275
+ import run # noqa: E402
276
+
277
+ decide.MIN_ITEMS = 1 # dm1 ranked first on errors from here
278
+ SEEN.clear()
279
+ TRUTH[decide.shape(ODD)] = "unreachable" # what the stub's dm1 will say
280
+ assert run.uptime_class(ODD) == ("unrecognised", "nothing matched")
281
+ assert decide.classify_errors(limit=3, sleep=slept.append) == 1 and len(SEEN) == 1 # only the gremlins
282
+ assert SEEN[0]["state"] == decide.shape(ODD) and SEEN[0]["model"] == "d1"
283
+ assert decide.cached_error_class(ODD) == ("unreachable", "decided by dm1 at 0.85"), decide.cached_error_class(ODD)
284
+ assert decide.classify_errors(limit=3) == 0 # asked once per shape
285
+ assert decide.cached_error_class("something else entirely") is None
286
+ # uptime_class reads the decision (no call), the benchmark's labels never do.
287
+ assert run.uptime_class(ODD) == ("unreachable", "decided by dm1 at 0.85") and len(SEEN) == 1
288
+ assert run.uptime_class(ODD, decided=False) == ("unrecognised", "nothing matched")
289
+ assert decide.build_sets(["errors"])["errors"]["items"] == sets["errors"]["items"]
290
+ # A decided `empty` is never used: an error is not an answer, and empty counts as up.
291
+ import locked # noqa: E402
292
+ locked.update(decide.DECIDED, lambda d: d["errors"][decide.shape(ODD)].__setitem__("class", "empty"))
293
+ assert run.uptime_class(ODD)[0] == "unrecognised"
294
+ locked.update(decide.DECIDED, lambda d: d["errors"][decide.shape(ODD)].__setitem__("class", "unreachable"))
295
+
296
+ # ---- the provider-notice fallback: asked only for a seat's short reply that looks like one ----------
297
+
298
+ SEEN.clear()
299
+ NOTE = "Your plan's balance is low; top up soon to keep going."
300
+ assert run.provider_notice("ready") is None and run.provider_notice("ready", ask=True) is None
301
+ assert run.provider_notice("The limit here is " + "x " * 200, ask=True) is None # long: an answer
302
+ assert run.provider_notice(NOTE) is None and not SEEN # a page or a score never asks
303
+ assert run.provider_notice("you have run out of credits") == "run out of credits" and not SEEN # the phrases first
304
+ assert run.provider_notice(NOTE, ask=True) == "decided by dm1 at 0.90" and len(SEEN) == 1
305
+ assert SEEN[0]["model"] == "d1" and SEEN[0]["questions"]["notice"]["type"] == "noul" # per request: not :free
306
+ assert decide.log_rows()[-1]["use"] == "notice"
307
+ assert run.provider_notice(NOTE, ask=True) == "decided by dm1 at 0.90" and len(SEEN) == 1 # kept: asked once
308
+ assert run.provider_notice(NOTE) == "decided by dm1 at 0.90" # and read anywhere
309
+ assert run.failure_class(NOTE) == "entitlement"
310
+
311
+ # ---- a role suggestion is kept, never set --------------------------------------------------------
312
+
313
+ hint = decide.suggest_role("m2", "model: vendor/m2\ncontext window: 8192\ndescription: A small coder.")
314
+ assert (hint["role"], hint["confidence"], hint["by"]) == ("general", 0.85, "dm1"), hint # the stub's first option
315
+ assert "decision" not in SEEN[-1]["questions"]["role"]["criteria"], SEEN[-1]
316
+ kept = json.loads(decide.DECIDED.read_text(encoding="utf-8"))
317
+ assert kept["roles"]["m2"]["role"] == hint["role"] and "unreachable" == kept["errors"][decide.shape(ODD)]["class"]
318
+ assert json.loads((TMP / "config" / "model-roles.json").read_text(encoding="utf-8"))["models"] == {"m1": "general"}
319
+
320
+ shutil.rmtree(TMP, ignore_errors=True)
29
321
  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 table() -> dict[str, dict]:
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.5.6",
3
+ "version": "0.6.1",
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",