@wendellresearch-ai/benchwarmers 0.5.3 → 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 +1 -1
- package/package.json +1 -1
- package/scripts/test_serve_edit.py +28 -0
- package/serve.py +54 -28
- package/suite/run.py +5 -3
- package/ui/bench.css +35 -29
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.5.
|
|
1
|
+
0.5.5
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.5.
|
|
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,34 @@ 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
|
+
|
|
483
|
+
# A provider's words end where its link begins, and are not cut short here:
|
|
484
|
+
# the page cuts them to a line, and a phone, with no hover, shows them whole.
|
|
485
|
+
text, url = serve.said("Error: Error code: 403 - {'error': {'message': 'm needs your consent. Give it once at"
|
|
486
|
+
" https://p.example/consents?model=m; it covers every model.'}}")
|
|
487
|
+
long, _ = serve.said("Error: Error code: 429 - {'error': {'message': 'Too many requests. " + "w " * 80 + "end'}}")
|
|
488
|
+
ok = text.endswith("Give it once at") and url == "https://p.example/consents?model=m" and long.endswith("end")
|
|
489
|
+
print(("ok " if ok else "FAIL ") + "action needed shows a provider's words whole, its link after them")
|
|
490
|
+
failures += 0 if ok else 1
|
|
491
|
+
|
|
464
492
|
# Dismiss: one picker key or many in one write, a change-log line each;
|
|
465
493
|
# undo brings back only the ones named; nothing named is refused.
|
|
466
494
|
serve.DISMISSED_PATH = cfg / "dismissed.json"
|
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
|
|
@@ -2007,7 +2012,9 @@ def said(detail: str) -> tuple[str, str]:
|
|
|
2007
2012
|
"""An error as one short line, and the link the provider put in it: the
|
|
2008
2013
|
status code and the provider's own words, without the JSON around them or
|
|
2009
2014
|
a lead-in such as "Too many requests." that says less than the sentence
|
|
2010
|
-
after it
|
|
2015
|
+
after it, ending where the link it gives begins (the link follows it on
|
|
2016
|
+
the page). Cut to a line by the page, not here: a phone shows it whole.
|
|
2017
|
+
The whole detail stays in the row's title."""
|
|
2011
2018
|
code = re.search(r"Error code: (\w+)", detail)
|
|
2012
2019
|
quoted = re.search(r"""['"](?:message|detail)['"]: ['"](.+?)(?:['"]\s*[,}]|$)""", detail)
|
|
2013
2020
|
text = (quoted.group(1) if quoted else re.sub(r"^.*?Error: ", "", detail) if "Error: " in detail
|
|
@@ -2017,8 +2024,16 @@ def said(detail: str) -> tuple[str, str]:
|
|
|
2017
2024
|
if rest and len(first) < 25:
|
|
2018
2025
|
text = rest
|
|
2019
2026
|
url = re.search(r"https?://[^\s;,'\"()]+", quoted.group(1) if quoted else "")
|
|
2027
|
+
if url and url.group(0) in text:
|
|
2028
|
+
text = text[:text.index(url.group(0))].rstrip()
|
|
2020
2029
|
text = (f"{code.group(1)} · " if code else "") + text
|
|
2021
|
-
return (text if len(text) <=
|
|
2030
|
+
return (text if len(text) <= 400 else text[:399].rstrip() + "…"), (url.group(0) if url else "")
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def breakable(name: str) -> str:
|
|
2034
|
+
"""An escaped name that may wrap at a domain's dots (`…-api.unorouter.com`),
|
|
2035
|
+
never inside a version number (`llama-3.2`)."""
|
|
2036
|
+
return re.sub(r"\.(?=[a-z])", ".<wbr>", name)
|
|
2022
2037
|
|
|
2023
2038
|
|
|
2024
2039
|
def open_actions() -> tuple[dict[str, dict], list[tuple[str, dict]]]:
|
|
@@ -2119,21 +2134,31 @@ def actions_page(groups: dict, auto: list) -> str:
|
|
|
2119
2134
|
shown = re.sub(r"^https?://", "", url).split("?")[0]
|
|
2120
2135
|
return f" <a href='{esc(url)}' target='_blank' rel='noopener'>{esc(shown)} ↗</a>"
|
|
2121
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
|
+
|
|
2122
2141
|
def model_row(r: dict) -> str:
|
|
2123
2142
|
if not r["alias"]: # a notice: what it says, and Got it if it goes once seen
|
|
2124
2143
|
got = (f"<button class='linkish' data-act='dismiss' data-key='{esc(r['key'])}'>Got it</button>"
|
|
2125
2144
|
if r.get("dismiss") else "")
|
|
2126
|
-
return (f"<
|
|
2127
|
-
f"<
|
|
2128
|
-
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>")
|
|
2129
2147
|
alias = esc(r["alias"])
|
|
2130
|
-
return (f"<
|
|
2131
|
-
f"
|
|
2132
|
-
|
|
2133
|
-
f"<
|
|
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}'"
|
|
2134
2152
|
" title='One call now; an answer clears the alert'>Retry</button>"
|
|
2135
2153
|
f"<button class='linkish danger' data-act='retire' data-alias='{alias}'"
|
|
2136
|
-
f" data-why='{esc(r['why'] or r['label'])}'>Retire</button></
|
|
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>")
|
|
2137
2162
|
|
|
2138
2163
|
def section(conn: str, g: dict) -> str:
|
|
2139
2164
|
aliases = [r["alias"] for r in g["rows"] if r["alias"]]
|
|
@@ -2146,29 +2171,30 @@ def actions_page(groups: dict, auto: list) -> str:
|
|
|
2146
2171
|
f"<div class='a-held' title='{esc(h['title'])}'><span class='chip'>held</span><span>Every model"
|
|
2147
2172
|
f" here is failing, likely the connection (key, credit, prefix), so nothing was retired."
|
|
2148
2173
|
f" Latest: {esc(h['said'])}{link(h['url'])}</span></div>" for h in g["held"])
|
|
2149
|
-
n = len(g["rows"])
|
|
2150
|
-
return (f"<div class='
|
|
2151
|
-
f"
|
|
2152
|
-
f"<span class='
|
|
2153
|
-
|
|
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>")
|
|
2154
2180
|
|
|
2155
2181
|
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2156
2182
|
gone = "".join(
|
|
2157
|
-
f"<
|
|
2158
|
-
f"<span class='alias'>{esc(alias)}</span></a><
|
|
2159
|
-
f"
|
|
2160
|
-
|
|
2161
|
-
f"
|
|
2162
|
-
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>"
|
|
2163
2188
|
for alias, r in auto)
|
|
2164
2189
|
# Folded: a week of automatic retirements is a record to check, not a list of things to do.
|
|
2165
|
-
folded = (f"<details class='a-retired'><summary>Retired automatically
|
|
2166
|
-
f" <span class='muted'
|
|
2167
|
-
if auto else "")
|
|
2168
|
-
body = "".join(section(conn, g) for conn, g in groups.items())
|
|
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())
|
|
2169
2194
|
return ("<div class='panel'><h2>Action needed</h2><p class='sub'>States that will not fix themselves, by the"
|
|
2170
2195
|
" connection they come from. A reason's full error is on hover, and a name opens the model."
|
|
2171
|
-
|
|
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}")
|
|
2172
2198
|
|
|
2173
2199
|
|
|
2174
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,
|
|
119
|
-
|
|
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-
|
|
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,23 +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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.a-
|
|
140
|
-
.a-
|
|
141
|
-
.a-
|
|
142
|
-
.a-
|
|
143
|
-
|
|
144
|
-
.a-
|
|
145
|
-
|
|
146
|
-
.a-
|
|
147
|
-
.a-
|
|
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. */
|
|
133
|
+
#tab-needs > .panel { container-type: inline-size; }
|
|
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; }
|
|
140
|
+
/* One line a model: the provider's words cut to the column, whole on hover; the link gives way last. */
|
|
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; }
|
|
148
146
|
.linkish.danger { color: var(--err); }
|
|
149
|
-
.a-retired
|
|
150
|
-
.a-retired > summary {
|
|
151
|
-
.a-retired > summary .muted { color: var(--muted);
|
|
147
|
+
.a-retired > summary { cursor: pointer; }
|
|
148
|
+
.a-retired > summary h3 { display: inline; }
|
|
149
|
+
.a-retired > summary .muted { color: var(--muted); }
|
|
152
150
|
.a-none { color: var(--muted); }
|
|
153
151
|
|
|
154
152
|
/* pages: a grouped sidebar (Observe, Gateway, Control); the group names and
|
|
@@ -542,11 +540,19 @@ dialog .pick { display: flex; gap: 1.5rem; }
|
|
|
542
540
|
button.c-row { grid-template-columns: minmax(0, 1fr); } /* the seats get the width; the whole row opens */
|
|
543
541
|
.c-row .open-hint { display: none; }
|
|
544
542
|
.a-line { margin: 1rem 1rem 0; }
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
543
|
+
}
|
|
544
|
+
/* A narrow page (a phone, or a window with the sidebar beside it): name and
|
|
545
|
+
state on one line, the words and actions under them. No gap between name
|
|
546
|
+
and state, so the line above a model runs unbroken. */
|
|
547
|
+
@container (max-width: 46rem) {
|
|
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; }
|
|
550
556
|
}
|
|
551
557
|
@media (prefers-reduced-motion: reduce) {
|
|
552
558
|
* { transition: none !important; }
|