@wendellresearch-ai/benchwarmers 0.5.1 → 0.5.2
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/README.md +2 -1
- package/USAGE.md +7 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/auto_retire.py +44 -4
- package/serve.py +5 -3
- package/suite/run.py +1 -0
- package/ui/bench.js +5 -8
package/README.md
CHANGED
|
@@ -187,7 +187,8 @@ dependencies either way: the app is the Python standard library.
|
|
|
187
187
|
- **Health.** Every morning each model gets a one-word prompt; once a week, a
|
|
188
188
|
short and a long one for speed. A model that stops answering, or that its
|
|
189
189
|
endpoint no longer serves, is retired automatically after one confirming
|
|
190
|
-
call, with the evidence kept, and can be restored in one click.
|
|
190
|
+
call, with the evidence kept, and can be restored in one click. A model
|
|
191
|
+
that is only out of quota until a stated reset is not counted against. The
|
|
191
192
|
built-in scheduler runs all of this once Settings switches it on; a fresh
|
|
192
193
|
install spends no calls until then.
|
|
193
194
|
- **Rate limits and usage.** Limits come from providers' rate-limit headers
|
package/USAGE.md
CHANGED
|
@@ -37,8 +37,13 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
37
37
|
|
|
38
38
|
"Action needed", above the pages, lists what will not fix itself, such as a
|
|
39
39
|
model about to be retired, naming each model and where it comes from, with
|
|
40
|
-
Retry, Open and Retire now beside it.
|
|
41
|
-
|
|
40
|
+
Retry, Open and Retire now beside it. It starts folded to its heading and
|
|
41
|
+
count; show opens it in that browser for 12 hours. An alert whose models have
|
|
42
|
+
all been retired goes at once. A model that is out of quota is not counting
|
|
43
|
+
down: days the bench held its calls back until a stated reset, or an account's
|
|
44
|
+
shared free quota was spent, pause its clock, and while it is still out of
|
|
45
|
+
quota no countdown shows. One refused again after the short wait it named is
|
|
46
|
+
failing, and keeps counting.
|
|
42
47
|
|
|
43
48
|
**Restart** (Settings, Server and access), or `bench restart`, starts the
|
|
44
49
|
dashboard again on the code now on disk, after an update. It waits for
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.5.
|
|
1
|
+
0.5.2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
package/scripts/auto_retire.py
CHANGED
|
@@ -19,6 +19,14 @@ THE RULES (decided 2026-10-15)
|
|
|
19
19
|
reason -- a rate limit that never lifts is a quota or a dead model.
|
|
20
20
|
`mistral-medium-3-5` answered 429 to 11 calls out of 11.
|
|
21
21
|
- NEVER on a rate limit or timeout alone inside those 7 days.
|
|
22
|
+
- OUT OF QUOTA IS NOT FAILING (2026-11-02): a call the bench held back because
|
|
23
|
+
a limit is spent until a stated reset, or an account-wide quota, says
|
|
24
|
+
nothing about the model. Its days pause the clock, and while it is the
|
|
25
|
+
latest word there is no countdown. `gpt-5.6-luna` sat on "retires in 3
|
|
26
|
+
days" while CLI Proxy API's codex sign-in was out until 26 November and the
|
|
27
|
+
bench had not sent it a call since 28 October. A refusal that names a short
|
|
28
|
+
wait and is refused again after it still counts: that is a model that keeps
|
|
29
|
+
failing, not one that is tapped out.
|
|
22
30
|
|
|
23
31
|
"No answer" means from ANY source: the perf probe, a useful offload, or a
|
|
24
32
|
suite output that was a real answer. A model that answers in the suite resets
|
|
@@ -68,6 +76,16 @@ def account_quota(detail: str) -> bool:
|
|
|
68
76
|
return any(marker in detail for marker in ACCOUNT_QUOTAS)
|
|
69
77
|
|
|
70
78
|
|
|
79
|
+
HELD = "not sent; paced by the bench" # metrics/pacing.py's refusal
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tapped_out(detail: str) -> bool:
|
|
83
|
+
"""Out of quota right now, so the model could not be tried: the bench held
|
|
84
|
+
the call back (a limit spent until its reset, the provider's or a cap you
|
|
85
|
+
set), or the account's shared quota is spent (account_quota)."""
|
|
86
|
+
return HELD in detail or account_quota(detail)
|
|
87
|
+
|
|
88
|
+
|
|
71
89
|
def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
72
90
|
first_missing: dict[str, str], rows: list[dict],
|
|
73
91
|
answers: dict[str, dt.date], restored: dict[str, dt.date],
|
|
@@ -87,15 +105,18 @@ def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
|
87
105
|
down: dict[str, bool] = {}
|
|
88
106
|
candidates: list[tuple[str, str, str]] = [] # (alias, bucket, why)
|
|
89
107
|
for alias, served in models.items():
|
|
90
|
-
mine =
|
|
108
|
+
mine = sorted((r for r in rows if r["alias"] == alias), key=lambda r: r["date"]) # [-1] is the latest word
|
|
91
109
|
last_ok = max([r["date"] for r in mine if r["answered"]]
|
|
92
110
|
+ ([answers[alias]] if alias in answers else []),
|
|
93
111
|
default=None)
|
|
94
112
|
floor = max(d for d in (last_ok, restored.get(alias), dt.date.min) if d)
|
|
95
113
|
# A refused key (`signin`) is the endpoint's, fixed by signing in again.
|
|
96
114
|
fails = sorted((r for r in mine if not r["answered"] and r["failure"] and r["failure"] != "signin"
|
|
97
|
-
and r["date"] > floor and not
|
|
115
|
+
and r["date"] > floor and not tapped_out(r["detail"])),
|
|
98
116
|
key=lambda r: r["date"])
|
|
117
|
+
quiet = {r["date"] for r in mine if not r["answered"] and r["date"] > floor
|
|
118
|
+
and tapped_out(r["detail"])}
|
|
119
|
+
resting = bool(mine) and not mine[-1]["answered"] and tapped_out(mine[-1]["detail"])
|
|
99
120
|
missing_since = None
|
|
100
121
|
if served not in live and served in first_missing:
|
|
101
122
|
missing_since = max(dt.date.fromisoformat(first_missing[served]),
|
|
@@ -107,8 +128,10 @@ def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
|
107
128
|
why = (f"{latest['failure']}: {latest['detail']}".rstrip(": ")
|
|
108
129
|
if latest else "")
|
|
109
130
|
other_days = {r["date"] for r in fails if r["failure"] == "other"}
|
|
110
|
-
span = (today - fails[0]["date"]).days if fails else 0
|
|
111
131
|
fail_days = {r["date"] for r in fails}
|
|
132
|
+
# Days it was out of quota, and nothing else, do not count: the clock pauses.
|
|
133
|
+
span = ((today - fails[0]["date"]).days
|
|
134
|
+
- len({q for q in quiet - fail_days if q > fails[0]["date"]})) if fails else 0
|
|
112
135
|
|
|
113
136
|
# A CONNECTION PREFIX ADDED IN OPEN WEBUI renames every model on it
|
|
114
137
|
# (`codestral-latest` -> `llm7.codestral-latest`), and the old names
|
|
@@ -146,7 +169,7 @@ def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
|
146
169
|
left.append((OTHER_DAYS - len(other_days), why))
|
|
147
170
|
if fails:
|
|
148
171
|
left.append((BACKSTOP_DAYS - span, why))
|
|
149
|
-
if left:
|
|
172
|
+
if left and not resting: # resting: the Quota page says when it is back
|
|
150
173
|
days, reason = min(left)
|
|
151
174
|
out["pending"].append((alias, f"{max(days, 1)}|{reason}"))
|
|
152
175
|
|
|
@@ -351,6 +374,23 @@ def _selfcheck() -> None:
|
|
|
351
374
|
detail="429 Rate limit exceeded: free-models-per-day") for n in (8, 5, 0)]
|
|
352
375
|
p = plan(d, {"quota": "v/quota"}, {"v/quota"}, {}, shared, {}, {}, {"v/quota": "v"})
|
|
353
376
|
assert not p["retire"] and not p["pending"], p
|
|
377
|
+
# held back by the bench for days (its codex sign-in spent until a reset): no countdown
|
|
378
|
+
held = [dict(row("luna", n, "capacity"), detail=f"rate limit ({HELD}): 0 left") for n in (9, 5, 0)]
|
|
379
|
+
p = plan(d, {"luna": "c/luna"}, {"c/luna"}, {}, held, {}, {}, {"c/luna": "c"})
|
|
380
|
+
assert not p["retire"] and not p["pending"], p
|
|
381
|
+
# real failures, then out of quota for a week: those days pause the clock
|
|
382
|
+
paused = ([row("p", n, "capacity") for n in (10, 9, 8, 0)]
|
|
383
|
+
+ [dict(row("p", n, "capacity"), detail=HELD) for n in range(1, 8)])
|
|
384
|
+
p = plan(d, {"p": "c/p"}, {"c/p"}, {}, paused, {}, {}, {"c/p": "c"})
|
|
385
|
+
assert not p["retire"] and dict(p["pending"])["p"].startswith("4|"), p
|
|
386
|
+
# ...and while it is still out of quota, no countdown is shown at all
|
|
387
|
+
p = plan(d, {"p": "c/p"}, {"c/p"}, {}, paused[:3] + paused[4:], {}, {}, {"c/p": "c"})
|
|
388
|
+
assert not p["retire"] and not p["pending"], p
|
|
389
|
+
# a short stated wait, refused again day after day, is a model that keeps failing
|
|
390
|
+
busy = [dict(row("u", n, "capacity"), detail="429 allows 1 request(s) every 1 min, retry in 60s")
|
|
391
|
+
for n in (8, 5, 0)]
|
|
392
|
+
p = plan(d, {"u": "v/u"}, {"v/u"}, {}, busy, {}, {}, {"v/u": "v"})
|
|
393
|
+
assert {a for a, _ in p["retire"]} == {"u"}, p
|
|
354
394
|
# A refused key is the endpoint's, never the model's (suite/run.py SIGNIN)
|
|
355
395
|
from run import failure_class
|
|
356
396
|
assert failure_class("Error: Error code: 401 - {'error': 'invalid key'}") == "signin"
|
package/serve.py
CHANGED
|
@@ -2078,11 +2078,13 @@ def problems() -> str:
|
|
|
2078
2078
|
folded = (f"<details><summary>Retired automatically this week · {len(auto)}"
|
|
2079
2079
|
" <span class='muted'>— each can be restored</span></summary>"
|
|
2080
2080
|
f"{render_rows(auto)}</details>") if auto else ""
|
|
2081
|
-
|
|
2081
|
+
# Folded by default, the count in its heading; bench.js keeps it open for
|
|
2082
|
+
# 12 hours once shown.
|
|
2083
|
+
return (f"<section class='problems collapsed' id='actions' aria-labelledby='actions-head'>"
|
|
2082
2084
|
f"<div class='head'><h2 id='actions-head'>Action needed · {len(items)}</h2>"
|
|
2083
2085
|
"<span class='sep' aria-hidden='true'>·</span>"
|
|
2084
|
-
"<button type='button' class='linkish' id='actions-hide' aria-expanded='
|
|
2085
|
-
" title='
|
|
2086
|
+
"<button type='button' class='linkish' id='actions-hide' aria-expanded='false'"
|
|
2087
|
+
" title='Open it here for 12 hours'>show</button></div>"
|
|
2086
2088
|
+ ("<p>States that will not fix themselves.</p>" if items
|
|
2087
2089
|
else "<p>Nothing open.</p>")
|
|
2088
2090
|
+ f"{render_rows(items)}{folded}</section>")
|
package/suite/run.py
CHANGED
|
@@ -554,6 +554,7 @@ STRUCTURAL = (
|
|
|
554
554
|
"unknown model",
|
|
555
555
|
"invalid model",
|
|
556
556
|
"model_not_found",
|
|
557
|
+
"no model found", # Routeway: "No model found: <id>"
|
|
557
558
|
)
|
|
558
559
|
# ENTITLEMENT is its own class, and the difference decides what to DO.
|
|
559
560
|
# `structural` means the model is not there and the registration should go.
|
package/ui/bench.js
CHANGED
|
@@ -123,23 +123,20 @@
|
|
|
123
123
|
c.addEventListener(c.tagName === 'SELECT' ? 'change' : 'input', function () { applyFilter(scope); });
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
// ---- Action needed:
|
|
127
|
-
//
|
|
126
|
+
// ---- Action needed: folded to its heading by default; show keeps it open
|
|
127
|
+
// in this browser for 12 hours, Hide folds it again ----
|
|
128
128
|
var acts = $('actions');
|
|
129
129
|
if (acts) {
|
|
130
|
-
var
|
|
131
|
-
var hid = null;
|
|
132
|
-
try { hid = JSON.parse(store('bench.actions.hidden') || 'null'); } catch (err) { hid = null; }
|
|
130
|
+
var opened = Number(store('bench.actions.open')) || 0;
|
|
133
131
|
var fold = function (on) {
|
|
134
132
|
acts.classList.toggle('collapsed', on);
|
|
135
133
|
$('actions-hide').textContent = on ? 'show' : 'Hide';
|
|
136
134
|
$('actions-hide').setAttribute('aria-expanded', String(!on));
|
|
137
135
|
};
|
|
138
|
-
fold(
|
|
139
|
-
&& keys.every(function (k) { return hid.keys.indexOf(k) !== -1; })));
|
|
136
|
+
fold(!(Date.now() - opened < 12 * 3600 * 1000));
|
|
140
137
|
$('actions-hide').addEventListener('click', function () {
|
|
141
138
|
var on = !acts.classList.contains('collapsed');
|
|
142
|
-
store('bench.actions.
|
|
139
|
+
store('bench.actions.open', on ? '' : String(Date.now()));
|
|
143
140
|
fold(on);
|
|
144
141
|
});
|
|
145
142
|
}
|