@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 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. The
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. Hide folds it away until something new
41
- turns up; an alert whose models have all been retired goes at once.
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
1
+ 0.5.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.5.1",
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",
@@ -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 = [r for r in rows if r["alias"] == alias]
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 account_quota(r["detail"])),
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
- return (f"<section class='problems' id='actions' aria-labelledby='actions-head'>"
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='true'"
2085
- " title='Fold this away until something new needs you, or for 12 hours'>Hide</button></div>"
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: Hide folds it to its heading, per browser, until an
127
- // action it has not seen appears or 12 hours pass, whichever is first ----
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 keys = Array.prototype.map.call(acts.querySelectorAll('.problem[data-key]'), function (p) { return p.dataset.key; });
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(!!(hid && hid.keys && Date.now() - hid.at < 12 * 3600 * 1000
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.hidden', on ? JSON.stringify({at: Date.now(), keys: keys}) : '');
139
+ store('bench.actions.open', on ? '' : String(Date.now()));
143
140
  fold(on);
144
141
  });
145
142
  }