@wendellresearch-ai/benchwarmers 0.5.4 → 0.5.5

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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.4
1
+ 0.5.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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",
@@ -461,6 +461,25 @@ def main() -> int:
461
461
  print(("ok " if ok else "FAIL ") + "action needed drops alerts on retired models")
462
462
  failures += 0 if ok else 1
463
463
 
464
+ # Retry is one request: no client retries after a 429 (2026-11-02: each
465
+ # UnoRouter Retry waited its 60 s and sent a second, refused, request).
466
+ import run
467
+ asked, kept_ask = {}, run.ask_once
468
+
469
+ def fake_ask(*a, **kw):
470
+ asked.update(kw)
471
+ raise StopIteration # stop before anything is written
472
+ run.ask_once = fake_ask
473
+ try:
474
+ serve.retry_model(next(iter(serve.registered().values())))
475
+ except StopIteration:
476
+ pass
477
+ finally:
478
+ run.ask_once = kept_ask
479
+ ok = asked.get("retries") == 0
480
+ print(("ok " if ok else "FAIL ") + "Retry sends one request, no client retries" + ("" if ok else f" -> {asked}"))
481
+ failures += 0 if ok else 1
482
+
464
483
  # A provider's words end where its link begins, and are not cut short here:
465
484
  # the page cuts them to a line, and a phone, with no hover, shows them whole.
466
485
  text, url = serve.said("Error: Error code: 403 - {'error': {'message': 'm needs your consent. Give it once at"
package/serve.py CHANGED
@@ -701,12 +701,17 @@ def retry_model(alias: str, who: str = "ui") -> dict:
701
701
  """One fresh call to a model on a retiring alert. An answer is written to
702
702
  perf/history.csv as evidence (scripts/auto_retire.py reads it, so the
703
703
  clock resets) and the alert is cleared now rather than at the next 5am
704
- check. A failure writes nothing: the daily probe keeps the record."""
704
+ check. A failure writes nothing: the daily probe keeps the record.
705
+
706
+ One request, never the client's retries: a 429's stated wait (UnoRouter's
707
+ 60 s) then a second request spent a minute and another call of a 1-a-minute
708
+ free tier, and Retry all did that for each model in turn. A person can
709
+ click again."""
705
710
  names = registered()
706
711
  if alias not in names.values():
707
712
  return {"ok": False, "errors": [f"{alias!r} is not registered"]}
708
713
  from run import ask_once, failure_class, llm_binary
709
- body, seconds = ask_once(llm_binary(), alias, "Reply with exactly one word: ready", 90)
714
+ body, seconds = ask_once(llm_binary(), alias, "Reply with exactly one word: ready", 90, retries=0)
710
715
  cls = failure_class(body, min_length=1)
711
716
  if cls is not None:
712
717
  try: # a stated quota or wait still teaches a cooldown
@@ -2129,21 +2134,31 @@ def actions_page(groups: dict, auto: list) -> str:
2129
2134
  shown = re.sub(r"^https?://", "", url).split("?")[0]
2130
2135
  return f" <a href='{esc(url)}' target='_blank' rel='noopener'>{esc(shown)} ↗</a>"
2131
2136
 
2137
+ def why(text: str, url: str, title: str) -> str:
2138
+ return (f"<td class='why' title='{esc(title)}'><div class='said-line'><span class='said'>{esc(text)}</span>"
2139
+ f"{link(url)}</div></td>")
2140
+
2132
2141
  def model_row(r: dict) -> str:
2133
2142
  if not r["alias"]: # a notice: what it says, and Got it if it goes once seen
2134
2143
  got = (f"<button class='linkish' data-act='dismiss' data-key='{esc(r['key'])}'>Got it</button>"
2135
2144
  if r.get("dismiss") else "")
2136
- return (f"<div class='a-row' data-row><span class='what'>{esc(r['said'])}</span>"
2137
- f"<span>{chip(r['label'], r['css'])}</span><span class='why'>{esc(r['title'])}</span>"
2138
- f"<span class='acts'>{got}</span></div>")
2145
+ return (f"<tr data-row><td class='what'>{esc(r['said'])}</td><td>{chip(r['label'], r['css'])}</td>"
2146
+ f"<td class='why'>{esc(r['title'])}</td><td class='acts'>{got}</td></tr>")
2139
2147
  alias = esc(r["alias"])
2140
- return (f"<div class='a-row' data-row><a href='#' class='model' data-alias='{alias}'>"
2141
- f"<span class='alias'>{breakable(alias)}</span></a><span>{chip(r['label'], r['css'])}</span>"
2142
- f"<span class='why' title='{esc(r['title'])}'><span class='said'>{esc(r['said'])}</span>{link(r['url'])}</span>"
2143
- f"<span class='acts'><button class='linkish' data-act='retry' data-alias='{alias}'"
2148
+ return (f"<tr data-row><td><a href='#' class='model' data-alias='{alias}'><span class='alias'>{breakable(alias)}"
2149
+ f"</span></a></td><td data-sort='{r['days']:03d}{esc(r['label'])}'>{chip(r['label'], r['css'])}</td>"
2150
+ + why(r["said"], r["url"], r["title"])
2151
+ + f"<td class='acts'><button class='linkish' data-act='retry' data-alias='{alias}'"
2144
2152
  " title='One call now; an answer clears the alert'>Retry</button>"
2145
2153
  f"<button class='linkish danger' data-act='retire' data-alias='{alias}'"
2146
- f" data-why='{esc(r['why'] or r['label'])}'>Retire</button></span></div>")
2154
+ f" data-why='{esc(r['why'] or r['label'])}'>Retire</button></td></tr>")
2155
+
2156
+ # One column plan for every table, as on Models, so they line up down the page.
2157
+ cols = "<colgroup><col class='c-model'><col class='c-state'><col><col class='c-acts'></colgroup>"
2158
+
2159
+ def table(first: str, second: str, rows: str) -> str:
2160
+ return (f"<table class='a-table'>{cols}<thead><tr>{th(first)}{th(second)}{th('Reason')}<th></th></tr></thead>"
2161
+ f"<tbody>{rows}</tbody></table>")
2147
2162
 
2148
2163
  def section(conn: str, g: dict) -> str:
2149
2164
  aliases = [r["alias"] for r in g["rows"] if r["alias"]]
@@ -2156,29 +2171,30 @@ def actions_page(groups: dict, auto: list) -> str:
2156
2171
  f"<div class='a-held' title='{esc(h['title'])}'><span class='chip'>held</span><span>Every model"
2157
2172
  f" here is failing, likely the connection (key, credit, prefix), so nothing was retired."
2158
2173
  f" Latest: {esc(h['said'])}{link(h['url'])}</span></div>" for h in g["held"])
2159
- n = len(g["rows"])
2160
- return (f"<div class='a-group'><div class='a-head' data-row><span class='conn'>{esc(conn)}</span>"
2161
- f"<span class='count'>{n} {'notice' if conn == 'Notices' else 'model'}{'s' * (n != 1)}</span>"
2162
- f"<span class='why'></span><span class='acts'>{both}</span></div>{held}"
2163
- f"<div class='a-rows'>{''.join(model_row(r) for r in g['rows'])}</div></div>")
2174
+ n, notices = len(g["rows"]), conn == "Notices"
2175
+ return (f"<div class='panel'><div class='a-head' data-row><h3 class='chart-head'>{esc(conn)} · {n}"
2176
+ f" {'notice' if notices else 'model'}{'s' * (n != 1)}</h3><span class='why'></span>"
2177
+ f"<span class='acts'>{both}</span></div>{held}"
2178
+ + table("Notice" if notices else "Model", "Kind" if notices else "State",
2179
+ "".join(model_row(r) for r in g["rows"])) + "</div>")
2164
2180
 
2165
2181
  tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
2166
2182
  gone = "".join(
2167
- f"<div class='a-row' data-row><a href='#' class='model' data-alias='{esc(alias)}'>"
2168
- f"<span class='alias'>{breakable(esc(alias))}</span></a><span class='when'>{esc(r['at'][:10])}</span>"
2169
- f"<span class='why' title='{esc(r.get('why', ''))}'><span class='said'>"
2170
- f"{esc(tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default())}"
2171
- f" · {esc(said(r.get('why', '').removeprefix('auto: '))[0])}</span></span>"
2172
- f"<span class='acts'><button class='linkish' data-act='restore' data-alias='{esc(alias)}'>Restore</button></span></div>"
2183
+ f"<tr data-row><td><a href='#' class='model' data-alias='{esc(alias)}'>"
2184
+ f"<span class='alias'>{breakable(esc(alias))}</span></a></td><td class='when'>{esc(r['at'][:10])}</td>"
2185
+ + why(f"{tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default()}"
2186
+ f" · {said(r.get('why', '').removeprefix('auto: '))[0]}", "", r.get("why", ""))
2187
+ + f"<td class='acts'><button class='linkish' data-act='restore' data-alias='{esc(alias)}'>Restore</button></td></tr>"
2173
2188
  for alias, r in auto)
2174
2189
  # Folded: a week of automatic retirements is a record to check, not a list of things to do.
2175
- folded = (f"<details class='a-retired'><summary>Retired automatically this week · {len(auto)}"
2176
- f" <span class='muted'>— each can be restored</span></summary><div class='a-rows'>{gone}</div></details>"
2177
- if auto else "")
2178
- body = "".join(section(conn, g) for conn, g in groups.items()) or "<p class='a-none'>Nothing open.</p>"
2190
+ folded = (f"<div class='panel'><details class='a-retired'><summary><h3 class='chart-head'>Retired automatically"
2191
+ f" this week · {len(auto)}</h3> <span class='muted'>each can be restored</span></summary>"
2192
+ f"{table('Model', 'Retired', gone)}</details></div>" if auto else "")
2193
+ body = "".join(section(conn, g) for conn, g in groups.items())
2179
2194
  return ("<div class='panel'><h2>Action needed</h2><p class='sub'>States that will not fix themselves, by the"
2180
2195
  " connection they come from. A reason's full error is on hover, and a name opens the model."
2181
- f" Retry spends one call; an answer clears the alert.</p>{body}{folded}</div>")
2196
+ " Retry spends one call; an answer clears the alert.</p>"
2197
+ + ("" if body else "<p class='a-none'>Nothing open.</p>") + f"</div>{body}{folded}")
2182
2198
 
2183
2199
 
2184
2200
  CAP_LABELS = {"tools": "tools", "vision": "vision", "structured_output": "json", "reasoning": "reasons"}
package/suite/run.py CHANGED
@@ -920,9 +920,11 @@ def _child_env() -> dict:
920
920
 
921
921
 
922
922
  def ask_once(binary: str, model: str, prompt: str,
923
- timeout: int, images: list[str] | None = None) -> tuple[str, float]:
923
+ timeout: int, images: list[str] | None = None, retries: int | None = None) -> tuple[str, float]:
924
924
  # BENCH_CLIENT=builtin: the same call without the `llm` subprocess, giving
925
- # the same text for the same failure (metrics/client.py).
925
+ # the same text for the same failure (metrics/client.py). `retries`: the
926
+ # built-in client's tries after a 429, 5xx or connection error (its two
927
+ # by default); the llm backend keeps its SDK's own.
926
928
  if str(ROOT / "metrics") not in sys.path:
927
929
  sys.path.insert(0, str(ROOT / "metrics"))
928
930
  import client
@@ -935,7 +937,7 @@ def ask_once(binary: str, model: str, prompt: str,
935
937
  purpose="vision task")
936
938
  return client.output(res, timeout), res["seconds"]
937
939
  if client.backend(model) == "builtin":
938
- res = client.chat(model, prompt, timeout=timeout)
940
+ res = client.chat(model, prompt, timeout=timeout, retries=retries)
939
941
  return client.output(res, timeout), res["seconds"]
940
942
  started = time.monotonic()
941
943
  try:
package/ui/bench.css CHANGED
@@ -115,16 +115,11 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
115
115
  .problems .head { display: flex; align-items: baseline; gap: .5rem; }
116
116
  .problems .head h2 { margin-bottom: .25rem; }
117
117
 
118
- /* Action needed: its own page, by connection. Each model is one line of a
119
- grid shared by its group (name, state, the provider's words, actions), so
120
- the columns line up down the group. */
118
+ /* Action needed: its own page, a panel per connection as on Models, each a
119
+ table (name, state, the provider's words, actions). */
121
120
  .a-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin: 1.4rem 1.6rem 0;
122
121
  padding: .45rem .75rem; background: var(--err-ground); border-radius: var(--radius); }
123
- .a-group { padding: 1rem 0 .75rem; border-top: 1px solid var(--line); }
124
- .a-group:first-of-type { border-top: 0; padding-top: .5rem; }
125
- .a-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin-bottom: .4rem; }
126
- .a-head .conn { font-size: 1.0625rem; font-weight: 700; }
127
- .a-head .count { color: var(--muted); }
122
+ .a-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; }
128
123
  .a-head .why { font-size: .875rem; }
129
124
  .a-head .why:empty { display: none; }
130
125
  .a-head .acts { margin-left: auto; display: flex; gap: .5rem; }
@@ -132,26 +127,26 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
132
127
  background: var(--warn-ground); border-radius: var(--radius); font-size: .9375rem; overflow-wrap: anywhere; }
133
128
  .a-held .chip { flex: none; background: var(--ink); color: var(--paper); }
134
129
  .a-held a { color: var(--ink); font-weight: 500; }
135
- /* The same fixed columns in every group and the retired list, so state,
136
- words and actions line up down the whole page whatever each group's
137
- longest name; a long name wraps in its column. */
130
+ /* The same fixed columns in every table, so state, words and actions line
131
+ up down the whole page whatever each group's longest name; a long name
132
+ wraps in its column. */
138
133
  #tab-needs > .panel { container-type: inline-size; }
139
- .a-rows { display: grid; grid-template-columns: minmax(0, 32%) 8.25rem minmax(0, 1fr) 6.25rem;
140
- gap: 0 1rem; align-items: baseline; }
141
- .a-row { display: contents; }
142
- .a-row > * { padding: .3rem 0; border-top: 1px solid color-mix(in oklab, var(--line) 55%, transparent); }
143
- .a-row:first-child > * { border-top: 0; }
144
- .a-row .alias, .a-row .what { font-weight: 500; overflow-wrap: anywhere; } /* a name breaks at its dots first (<wbr>) */
145
- .a-row .why, .a-row .when { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
134
+ table.a-table { table-layout: fixed; }
135
+ table.a-table col.c-model { width: 30%; }
136
+ table.a-table col.c-state { width: 9.5rem; }
137
+ table.a-table col.c-acts { width: 7.5rem; }
138
+ .a-table .alias, .a-table .what { font-weight: 500; overflow-wrap: anywhere; } /* a name breaks at its dots first (<wbr>) */
139
+ .a-table .why, .a-table .when { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
146
140
  /* One line a model: the provider's words cut to the column, whole on hover; the link gives way last. */
147
- .a-row .why:has(> .said) { display: flex; gap: .5rem; min-width: 0; }
148
- .a-row .said { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
149
- .a-row .why > a { flex: none; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
150
- .a-row .acts { display: flex; gap: .9rem; font-size: .875rem; }
141
+ .a-table .said-line { display: flex; gap: .5rem; min-width: 0; }
142
+ .a-table .said { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
143
+ .a-table .said-line > a { flex: none; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
144
+ .a-table td.acts { white-space: nowrap; font-size: .875rem; }
145
+ .a-table td.acts .linkish + .linkish { margin-left: .9rem; }
151
146
  .linkish.danger { color: var(--err); }
152
- .a-retired { margin-top: .75rem; border-top: 1px solid var(--line); padding-top: .5rem; }
153
- .a-retired > summary { cursor: pointer; font-weight: 500; padding: .35rem 0; }
154
- .a-retired > summary .muted { color: var(--muted); font-weight: 400; }
147
+ .a-retired > summary { cursor: pointer; }
148
+ .a-retired > summary h3 { display: inline; }
149
+ .a-retired > summary .muted { color: var(--muted); }
155
150
  .a-none { color: var(--muted); }
156
151
 
157
152
  /* pages: a grouped sidebar (Observe, Gateway, Control); the group names and
@@ -550,13 +545,14 @@ dialog .pick { display: flex; gap: 1.5rem; }
550
545
  state on one line, the words and actions under them. No gap between name
551
546
  and state, so the line above a model runs unbroken. */
552
547
  @container (max-width: 46rem) {
553
- .a-rows { grid-template-columns: minmax(0, 1fr) max-content; column-gap: 0; }
554
- .a-row > :nth-child(2) { padding-left: .75rem; }
555
- .a-row .why, .a-row .acts { grid-column: 1 / -1; padding-top: 0; border-top: 0; }
556
- .a-row .acts { padding-bottom: .5rem; }
557
- .a-row .why:has(> .said) { flex-wrap: wrap; }
558
- .a-row .said { white-space: normal; } /* the height to read it whole */
559
- .a-row .why > a { max-width: none; }
548
+ .a-table thead { display: none; }
549
+ .a-table, .a-table tbody { display: block; }
550
+ .a-table tr { display: grid; grid-template-columns: minmax(0, 1fr) max-content; border-bottom: 1px solid var(--line); }
551
+ .a-table td { border-bottom: 0; }
552
+ .a-table td.why, .a-table td.acts { grid-column: 1 / -1; padding-top: 0; }
553
+ .a-table .said-line { flex-wrap: wrap; }
554
+ .a-table .said { white-space: normal; } /* the height to read it whole */
555
+ .a-table .said-line > a { max-width: none; }
560
556
  }
561
557
  @media (prefers-reduced-motion: reduce) {
562
558
  * { transition: none !important; }