@wendellresearch-ai/benchwarmers 0.6.1 → 0.6.3
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/metrics/route.py +6 -4
- package/package.json +1 -1
- package/scripts/provider_tags.py +19 -3
- package/scripts/test_no_owui.py +13 -0
- package/serve.py +126 -55
- package/ui/bench.css +30 -8
- package/ui/bench.js +349 -154
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.3
|
package/metrics/route.py
CHANGED
|
@@ -148,12 +148,14 @@ def _full_tag_of(alias: str) -> str:
|
|
|
148
148
|
import endpoints
|
|
149
149
|
# Tags are keyed by the served name. A decision model is not in the llm
|
|
150
150
|
# yaml, so its served name comes from config/decision-models.json.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
or alias)
|
|
151
|
+
decision = _json(DATA / "config" / "decision-models.json").get(alias) or {}
|
|
152
|
+
served = _registered_names().get(alias) or decision.get("served") or alias
|
|
154
153
|
tag = _json(DATA / "config" / "provider-tags.json").get(served)
|
|
155
154
|
if not tag:
|
|
156
|
-
|
|
155
|
+
# A decision model is in no registration either: its own endpoint is its `via`
|
|
156
|
+
# (Cloudflare's clef, onboarded from a direct cloudflare endpoint).
|
|
157
|
+
name = (endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
|
|
158
|
+
or decision.get("via") or decision.get("endpoint"))
|
|
157
159
|
tag = name if name and not endpoints.hub(name) else None
|
|
158
160
|
return tag or "?"
|
|
159
161
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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/provider_tags.py
CHANGED
|
@@ -68,13 +68,29 @@ def get(hub: str, path: str) -> dict:
|
|
|
68
68
|
return json.load(response)
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _host(url: str) -> str:
|
|
72
|
+
return (url or "").split("//", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower()
|
|
73
|
+
|
|
74
|
+
|
|
71
75
|
def connection_names(hub: str) -> list[str]:
|
|
72
|
-
"""One short name per connection index. URLs only — never the keys.
|
|
76
|
+
"""One short name per connection index. URLs only — never the keys.
|
|
77
|
+
|
|
78
|
+
A provider reached both through the hub and on a direct endpoint of its
|
|
79
|
+
own (Cloudflare: chat through Open WebUI, decisions only direct) is ONE
|
|
80
|
+
connection: the hub's connection to that host takes the direct endpoint's
|
|
81
|
+
name, which is what its directly registered models are tagged (main). Any
|
|
82
|
+
provider, by host; other hubs (CLI Proxy API) are not direct endpoints."""
|
|
73
83
|
urls = get(hub, "/openai/config").get("OPENAI_API_BASE_URLS", [])
|
|
84
|
+
direct: dict[str, str] = {}
|
|
85
|
+
for name, entry in endpoints.load().items():
|
|
86
|
+
if name != hub and not endpoints.hub(name):
|
|
87
|
+
direct.setdefault(_host(entry.get("base", "")), name)
|
|
74
88
|
names = []
|
|
75
89
|
for url in urls:
|
|
76
|
-
host = url
|
|
77
|
-
if host.
|
|
90
|
+
host = _host(url)
|
|
91
|
+
if host in direct and host not in (endpoints.connections().get("hosts") or {}):
|
|
92
|
+
names.append(direct[host])
|
|
93
|
+
elif host.replace(".", "").isdigit():
|
|
78
94
|
# A BARE IP NAMES NOTHING, and guessing from it was worse than
|
|
79
95
|
# useless: a proxy labelled `lan-proxy` is described by where it
|
|
80
96
|
# sits rather than what it is, and Open WebUI does not expose
|
package/scripts/test_no_owui.py
CHANGED
|
@@ -236,6 +236,19 @@ def owner(fake: str) -> None:
|
|
|
236
236
|
assert tags["fast-1"] == "groq" and tags["llm7.codestral"] == "llm7-api" and tags["vendor/x:free"] == "openrouter", tags
|
|
237
237
|
assert "never-read" not in json.dumps(tags)
|
|
238
238
|
assert route.connection_of("fast-1") == "groq" and route.connection_of("x-free") == "openrouter"
|
|
239
|
+
# A provider also on a direct endpoint of its own is one connection: the hub's takes the endpoint's name.
|
|
240
|
+
real_load, both = endpoints.load, dict(endpoints.load())
|
|
241
|
+
both["groq-direct"] = {"base": "https://api.groq.com/openai/v1", "key": "groq-direct"}
|
|
242
|
+
endpoints.load = lambda *a, **k: both
|
|
243
|
+
try:
|
|
244
|
+
assert provider_tags.connection_names("openwebui") == ["groq-direct", "llm7-api"]
|
|
245
|
+
finally:
|
|
246
|
+
endpoints.load = real_load
|
|
247
|
+
# A decision model onboarded from the direct endpoint: in no registration, untagged, its `via` is its connection.
|
|
248
|
+
write("decision-models.json", {"dec-1": {"served": "vendor/decide:free", "endpoint": "openrouter", "via": "openrouter"},
|
|
249
|
+
"dec-hub": {"served": "hub-decide", "endpoint": "openwebui", "via": "openwebui"}})
|
|
250
|
+
assert route.connection_of("dec-1") == "openrouter" and route.connection_of("dec-hub") == "?"
|
|
251
|
+
(TMP / "config" / "decision-models.json").unlink()
|
|
239
252
|
|
|
240
253
|
# The default hub is recorded whole; the direct endpoint, its registered models only.
|
|
241
254
|
assert check_deprecations.served() == {"fast-1", "llm7.codestral", "fresh-2", "mercury-decide",
|
package/serve.py
CHANGED
|
@@ -39,6 +39,7 @@ import datetime
|
|
|
39
39
|
import functools
|
|
40
40
|
import html
|
|
41
41
|
import json
|
|
42
|
+
import math
|
|
42
43
|
import os
|
|
43
44
|
import pathlib
|
|
44
45
|
import re
|
|
@@ -2639,7 +2640,7 @@ def charts_data() -> dict:
|
|
|
2639
2640
|
"rate": int(hit[1]) / max(1, int(hit[2])) if hit else None, "invented": bool(v["invented"])})
|
|
2640
2641
|
bars = [{"model": m, "kind": k, "rate": w / r["answers"], "n": f"{w}/{n}"}
|
|
2641
2642
|
for m, r in sorted(wr.items()) if m in live and r["answers"] for k, (w, n) in r["by"].items() if w]
|
|
2642
|
-
totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"]}
|
|
2643
|
+
totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"], "wrong": r["wrong"]}
|
|
2643
2644
|
for m, r in wr.items() if m in live and r["answers"]}
|
|
2644
2645
|
hunt = [{"model": m, **r["hunt"]} for m, r in sorted(wr.items()) if m in live and any(r["hunt"].values())]
|
|
2645
2646
|
return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
|
|
@@ -2900,16 +2901,10 @@ def vision_card() -> str:
|
|
|
2900
2901
|
|
|
2901
2902
|
|
|
2902
2903
|
def perf_charts() -> dict:
|
|
2903
|
-
"""
|
|
2904
|
-
|
|
2904
|
+
"""Speed on real tasks against how much a model finds, for the Performance
|
|
2905
|
+
panel of Benchmarks. (Whether probes answered is Uptime's.)"""
|
|
2905
2906
|
import route
|
|
2906
2907
|
live = on_bench()
|
|
2907
|
-
since = (datetime.date.today() - datetime.timedelta(days=14)).isoformat()
|
|
2908
|
-
outcomes: dict[tuple[str, str], int] = {}
|
|
2909
|
-
for r in perf_rows():
|
|
2910
|
-
if r["model"] in live and r.get("timestamp", "") >= since:
|
|
2911
|
-
o = "answered" if int(r.get("answered") or 0) > 0 else (r.get("failure") or "error")
|
|
2912
|
-
outcomes[(r["model"], o)] = outcomes.get((r["model"], o), 0) + 1
|
|
2913
2908
|
seconds: dict[str, list[float]] = {}
|
|
2914
2909
|
for (_task, m), v in suite_scores().items():
|
|
2915
2910
|
if m in live and re.match(r"\d+/\d+", v["found"]):
|
|
@@ -2917,8 +2912,7 @@ def perf_charts() -> dict:
|
|
|
2917
2912
|
recall = route.suite_recall()
|
|
2918
2913
|
speed = [{"model": m, "seconds": sorted(s)[len(s) // 2], "recall": recall[m][0] / recall[m][1], "tasks": len(s)}
|
|
2919
2914
|
for m, s in sorted(seconds.items()) if recall.get(m) and recall[m][1]]
|
|
2920
|
-
return {"
|
|
2921
|
-
"speed": speed}
|
|
2915
|
+
return {"speed": speed}
|
|
2922
2916
|
|
|
2923
2917
|
|
|
2924
2918
|
# ---- Trend: does a model score lower, or higher, than it used to? ----------
|
|
@@ -3545,9 +3539,15 @@ DECIDE_KIND = {"errors": "choice of 6", "collection": "choice of the collections
|
|
|
3545
3539
|
def decision_data() -> dict:
|
|
3546
3540
|
"""For bench.js: each model's calibration bins per set, [said, right, n]."""
|
|
3547
3541
|
res = decide.results()
|
|
3548
|
-
|
|
3542
|
+
models = sorted((res.get("models") or {}).items())
|
|
3543
|
+
return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS], "low": decide.MIN_ITEMS,
|
|
3544
|
+
# Right of asked per model and set: the bars, as the tables count it.
|
|
3545
|
+
"scores": [{"model": a, "set": s, "correct": r.get("correct", 0), "asked": r["asked"],
|
|
3546
|
+
"answered": r.get("answered", 0), "brier": r.get("brier")}
|
|
3547
|
+
for a, by in models for s, r in by.items()
|
|
3548
|
+
if s in decide.SETS and isinstance(r, dict) and r.get("asked")],
|
|
3549
3549
|
"points": [{"model": a, "set": s, "said": b["said"], "right": b["right"], "n": b["n"]}
|
|
3550
|
-
for a, by in
|
|
3550
|
+
for a, by in models for s, r in by.items()
|
|
3551
3551
|
if s in decide.SETS for b in r.get("calibration") or []]}
|
|
3552
3552
|
|
|
3553
3553
|
|
|
@@ -3614,8 +3614,7 @@ def decision_card() -> str:
|
|
|
3614
3614
|
extra = (f"<h3 class='chart-head'>Who each use asks now</h3><ul class='links'>{''.join(uses)}</ul>"
|
|
3615
3615
|
f"<p class='hint'>{'Measured up to ' + esc(when[:16].replace('T', ' ')) + '.' if when else 'Not measured yet.'}"
|
|
3616
3616
|
" The scheduler's <code>decisions</code> job asks each model one question a set a day;"
|
|
3617
|
-
" <code>python metrics/decide.py bench</code> runs a pass by hand.</p>"
|
|
3618
|
-
"<h3 class='chart-head'>Calibration: said against right</h3>")
|
|
3617
|
+
" <code>python metrics/decide.py bench</code> runs a pass by hand.</p>")
|
|
3619
3618
|
key = f"""<dl>
|
|
3620
3619
|
<dt>The sets</dt><dd>Questions with known answers, built from the bench's own data, no hand labelling.
|
|
3621
3620
|
<b>Error classes</b>: failed calls' error text (redacted: no keys, ids or emails) and the class the
|
|
@@ -3627,8 +3626,12 @@ def decision_card() -> str:
|
|
|
3627
3626
|
picked, so always saying the commonest option scores low.</dd>
|
|
3628
3627
|
<dt>Brier</dt><dd>How far its probabilities sat from the truth: 0 is perfect; for a choice it is
|
|
3629
3628
|
summed over the options, so up to 2. Lower is better, and a sure wrong answer costs most.</dd>
|
|
3630
|
-
<dt>
|
|
3631
|
-
|
|
3629
|
+
<dt>The bars</dt><dd>Right of asked for each model on each set, from 0%. A faint bar is a model
|
|
3630
|
+
under {low} items on that set: shown, not ranked yet. Hover a bar for its Brier.</dd>
|
|
3631
|
+
<dt>Calibration</dt><dd>The second chart, once a model answers at two or more levels of sureness
|
|
3632
|
+
on a set: answers grouped by the probability the model gave its answer
|
|
3633
|
+
(x), against how often those answers were right (y); both axes start just under the lowest
|
|
3634
|
+
answer, not at 0%. On the diagonal, its 80% answers come true
|
|
3632
3635
|
80% of the time; under it, it is surer than it should be. A bigger dot is more answers. The gap is
|
|
3633
3636
|
the average distance from the diagonal, weighted by answers.</dd>
|
|
3634
3637
|
<dt>At {decide.CONFIDENT}</dt><dd>The gateway acts on a choice only when the model is this sure: how
|
|
@@ -3669,26 +3672,45 @@ def bughunt_card() -> str:
|
|
|
3669
3672
|
grey said there was no bug.</dd>
|
|
3670
3673
|
<dt>Left of the line</dt><dd>The same files untouched: red is a false alarm, claiming a
|
|
3671
3674
|
bug in clean code. The longer the red, the less to trust its "there's a bug here".</dd>
|
|
3672
|
-
<dt>Order</dt><dd>
|
|
3675
|
+
<dt>Order</dt><dd>On what is proven, best at the top: the low end of the found share's likely
|
|
3676
|
+
range (95%) less the high end of the false alarms'. A few tasks give wide ranges, so they
|
|
3677
|
+
rank low; a model never shown clean code could be raising alarms on all of it.</dd>
|
|
3678
|
+
<dt>Faint bars</dt><dd>Under 10 tasks: shown, but little to go on.</dd>
|
|
3673
3679
|
</dl>"""
|
|
3674
3680
|
return chart_card("Bug hunt", "Every task with an answer key: bugs found against false alarms on the"
|
|
3675
3681
|
" same files left clean. Right is good, left is bad.", "chart-hunt", "".join(body), key)
|
|
3676
3682
|
|
|
3677
3683
|
|
|
3684
|
+
def _wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
|
3685
|
+
"""The 95% Wilson interval of k successes in n, as bench.js wilson() draws it."""
|
|
3686
|
+
if not n:
|
|
3687
|
+
return 0.0, 1.0
|
|
3688
|
+
p, z2 = k / n, z * z
|
|
3689
|
+
mid, half = p + z2 / (2 * n), z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n))
|
|
3690
|
+
return max(0.0, (mid - half) / (1 + z2 / n)), min(1.0, (mid + half) / (1 + z2 / n))
|
|
3691
|
+
|
|
3692
|
+
|
|
3678
3693
|
def work_card() -> str:
|
|
3679
3694
|
"""How often real offloads helped."""
|
|
3680
3695
|
rows = work_rows()
|
|
3681
3696
|
body = ["<table><thead><tr>", th("Model"), th("Offloads", True), th("Judged", True), th("Helped", True),
|
|
3682
|
-
th("Verified findings", True), th("False claims", True), "</tr></thead><tbody>"]
|
|
3683
|
-
for r in sorted(rows, key=lambda r: -r["useful"]
|
|
3697
|
+
th("Likely between", True), th("Verified findings", True), th("False claims", True), "</tr></thead><tbody>"]
|
|
3698
|
+
for r in sorted(rows, key=lambda r: (-_wilson(r["useful"], r["judged"])[0], -r["judged"])):
|
|
3699
|
+
lo, hi = _wilson(r["useful"], r["judged"])
|
|
3684
3700
|
body.append(f"<tr><td>{named(r['model'])}</td><td class='num'>{r['offloads']}</td><td class='num'>{r['judged']}</td>"
|
|
3685
3701
|
f"<td class='num' data-sort='{r['useful'] / r['judged']:.3f}'>{r['useful']}/{r['judged']}</td>"
|
|
3702
|
+
f"<td class='num' data-sort='{lo:.3f}'>{lo * 100:.0f}–{hi * 100:.0f}%</td>"
|
|
3686
3703
|
f"<td class='num'>{r['real']}</td><td class='num'>{r['false']}</td></tr>")
|
|
3687
3704
|
body.append("</tbody></table>")
|
|
3688
3705
|
key = """<dl>
|
|
3689
3706
|
<dt>Helped</dt><dd>Of the offloads judged, the share recorded useful: real work (reviews,
|
|
3690
3707
|
enumerations) handed to the model and checked afterwards. Benchmarks never count here.</dd>
|
|
3691
|
-
<dt>The
|
|
3708
|
+
<dt>The dot and its line</dt><dd>The dot is the share that helped, bigger for more judged. The
|
|
3709
|
+
line is where the true share likely sits (a 95% Wilson interval): long for 3 of 3, short for
|
|
3710
|
+
40 of 50. Green when even its low end is over half, red when even its high end is under,
|
|
3711
|
+
grey when there are too few to say.</dd>
|
|
3712
|
+
<dt>Order</dt><dd>By the low end of that line, so volume counts: 40 of 50 ranks above 3 of 3.</dd>
|
|
3713
|
+
<dt>The dashed line</dt><dd>Every model's judged offloads together, for comparison.</dd>
|
|
3692
3714
|
<dt>Findings and false claims</dt><dd>Verified one by one in reviews, recorded since 2026-10-17.</dd>
|
|
3693
3715
|
</dl>"""
|
|
3694
3716
|
return chart_card("Real work", "When a model was given real work and the result was checked: how often"
|
|
@@ -3698,7 +3720,8 @@ def work_card() -> str:
|
|
|
3698
3720
|
def overview_card() -> str:
|
|
3699
3721
|
return (f"<div class='panel'>{SHARE}<h2>Who to trust with a review</h2><p class='sub'>Each dot is a model."
|
|
3700
3722
|
" Right: finds more of the real bugs in our own code. Up: makes fewer things up (confidently"
|
|
3701
|
-
" wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be
|
|
3723
|
+
" wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be."
|
|
3724
|
+
" The models in or near the box, and the three that make most up, are named; hover for the rest.</p>"
|
|
3702
3725
|
"<div class='chart' id='chart-overview'></div></div>")
|
|
3703
3726
|
|
|
3704
3727
|
|
|
@@ -3811,6 +3834,10 @@ def wrong_card() -> str:
|
|
|
3811
3834
|
<dt>Reviews</dt><dd>False claims recorded in verdicts (<code>offload.py verdict|add
|
|
3812
3835
|
--false "..."</code>), over judged offloads. Recorded in structured form only since
|
|
3813
3836
|
2026-10-17, so older reviews show none.</dd>
|
|
3837
|
+
<dt>The thin line</dt><dd>Where the true share likely sits (95% range). Long for a few
|
|
3838
|
+
answers: 0 of 2 is not a clean record, and the line shows how bad it could be.</dd>
|
|
3839
|
+
<dt>Order</dt><dd>Worst proven at the top: by the low end of that line. Under 10 answers a
|
|
3840
|
+
model's bar is faint.</dd>
|
|
3814
3841
|
</dl>"""
|
|
3815
3842
|
return chart_card("Confidently wrong", "Who asserts things that are not true: false alarms on clean"
|
|
3816
3843
|
" code, wrong lines, false claims confirmed, traps taken. Shorter is better.",
|
|
@@ -3874,17 +3901,14 @@ def perf_card() -> str:
|
|
|
3874
3901
|
when it is slow enough to change how you work.</dd>
|
|
3875
3902
|
</dl>"""
|
|
3876
3903
|
key = key.replace("</dl>", """
|
|
3877
|
-
<dt>Did it answer?</dt><dd>
|
|
3878
|
-
rate limit (capacity), a timeout, or a failure the bench classifies (structural, paywalled,
|
|
3879
|
-
entitlement, other).</dd>
|
|
3904
|
+
<dt>Did it answer?</dt><dd>Is on Uptime, by day, with our own quota and setup problems left out.</dd>
|
|
3880
3905
|
<dt>Speed vs quality</dt><dd>Median seconds per answer on our own suite tasks (real prompts,
|
|
3881
|
-
not the one-word probe) against the share of findings located. Top left is fast and good
|
|
3906
|
+
not the one-word probe) against the share of findings located. Top left is fast and good:
|
|
3907
|
+
the green box is the faster half and the top quarter of findings.</dd>
|
|
3882
3908
|
</dl>""")
|
|
3883
3909
|
return chart_card("Performance", "What the serving path does — not what the models are worth. One"
|
|
3884
3910
|
" reading is weather; the history is climate.", "chart-speed", body, key,
|
|
3885
|
-
extra="<h3 class='chart-head'>
|
|
3886
|
-
"<div class='chart' id='chart-reliability'></div>"
|
|
3887
|
-
"<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
|
|
3911
|
+
extra="<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
|
|
3888
3912
|
|
|
3889
3913
|
|
|
3890
3914
|
def favicon(theme: str) -> str:
|
|
@@ -4300,13 +4324,21 @@ def collections_tab() -> str:
|
|
|
4300
4324
|
" the paid ones. Click a suggestion or type your own: comma-separated lower-case words, up to 8.</p>"
|
|
4301
4325
|
# Not data-filter: that binds to the whole panel. The same match (any
|
|
4302
4326
|
# text, case-insensitive), on the member list only; the seats show who is ticked.
|
|
4303
|
-
"<label class='field' for='c-member-filter'>Members</label>"
|
|
4327
|
+
"<div class='members-head'><label class='field' for='c-member-filter'>Members</label>"
|
|
4328
|
+
# Model details as they are now (a fact set in a model's popup, a refresh of the
|
|
4329
|
+
# catalog), the ticks and the search kept; also done when a model popup opened from here closes.
|
|
4330
|
+
"<button class='btn small' type='button' id='c-refresh' title='Fetch the models and their details again;"
|
|
4331
|
+
" what is ticked stays ticked'>Refresh model details</button>"
|
|
4332
|
+
"<span class='done' id='c-refresh-out' role='status'></span></div>"
|
|
4304
4333
|
# The panel as it will sit, drawn as the Collections list draws it
|
|
4305
4334
|
# (bench.js recount): a seat per ticked member, explorer seats dashed.
|
|
4306
4335
|
"<div id='c-seats' class='seats' aria-live='polite'></div>"
|
|
4307
4336
|
"<div class='member-find'><input type='search' id='c-member-filter' class='member-filter' spellcheck='false'"
|
|
4308
4337
|
" autocomplete='off' placeholder='Filter by model, provider or served name' aria-controls='c-members'"
|
|
4309
|
-
" aria-describedby='c-count'><select id='c-member-
|
|
4338
|
+
" aria-describedby='c-count'><select id='c-member-show' aria-label='Show' aria-controls='c-members'>"
|
|
4339
|
+
"<option value=''>Every model</option><option value='ticked'>Ticked</option>"
|
|
4340
|
+
"<option value='fits'>Fits the tags</option></select>"
|
|
4341
|
+
"<select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
|
|
4310
4342
|
"<option value=''>Every provider</option></select></div>"
|
|
4311
4343
|
"<div id='c-members' class='members' role='group' aria-label='Members'></div>"
|
|
4312
4344
|
"<p class='rules' id='c-count'></p>"
|
|
@@ -4956,11 +4988,12 @@ def move_card() -> str:
|
|
|
4956
4988
|
"""Settings' Export and import (export_zip, import_zip; bench.js)."""
|
|
4957
4989
|
return ("<div class='panel form-panel'><h2>Export and import</h2><p class='muted'>Take this install to another"
|
|
4958
4990
|
" machine, or keep a copy of it: one .zip file, imported on the other install's Settings page.</p>"
|
|
4959
|
-
|
|
4991
|
+
# No wrapper: `.pick` is the onboarding picker's 3-column grid row, and squeezed these into it.
|
|
4992
|
+
"<label class='check'><input type='radio' name='x-scope' value='settings' checked"
|
|
4960
4993
|
" data-kept> <span><b>Settings only</b>: endpoints, collections, roles, model options, the schedule,"
|
|
4961
4994
|
" gateway keys, the theme (<code>config/</code>)</span></label>"
|
|
4962
4995
|
"<label class='check'><input type='radio' name='x-scope' value='all' data-kept> <span><b>Everything</b>:"
|
|
4963
|
-
" the settings, and every result, history, log and saved answer the bench judges by</span></label
|
|
4996
|
+
" the settings, and every result, history, log and saved answer the bench judges by</span></label>"
|
|
4964
4997
|
"<label class='check'><input type='checkbox' id='x-keys' data-kept> <span>With the endpoints' API keys."
|
|
4965
4998
|
" The file then holds them: keep it like a password, and delete it once imported.</span></label>"
|
|
4966
4999
|
"<div class='actions'><button class='btn' type='button' id='x-export'>Download export</button></div>"
|
|
@@ -5005,23 +5038,28 @@ def access_card() -> str:
|
|
|
5005
5038
|
body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td>{esc(v)}</td>"
|
|
5006
5039
|
f"<td class='mono set-by'>{'<br>'.join(esc(s) for s in src.split(', '))}</td></tr>"
|
|
5007
5040
|
for k, v, src in rows)
|
|
5008
|
-
running, disk = on_disk()
|
|
5009
|
-
now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
|
|
5010
|
-
f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
|
|
5011
5041
|
return ("<div class='panel'><h2>Server and access</h2><p class='muted'>How this install is reached and who"
|
|
5012
5042
|
" may call it. Set in the environment, read at start: change one and restart. Shown here, never"
|
|
5013
5043
|
" set here, since a page that could change its own login could open itself to anyone. No secret"
|
|
5014
5044
|
" is shown, only whether one is set.</p>"
|
|
5015
5045
|
f"<table class='facts'><thead><tr>{th('Setting')}{th('Now')}{th('Set by')}</tr></thead>"
|
|
5016
|
-
f"<tbody>{body}</tbody></table>"
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5046
|
+
f"<tbody>{body}</tbody></table>{restart_block('restart', 'a setting above')}</div>")
|
|
5047
|
+
|
|
5048
|
+
|
|
5049
|
+
def restart_block(id_: str, what: str) -> str:
|
|
5050
|
+
"""The Restart heading, what runs now, and its button (bench.js restart()).
|
|
5051
|
+
On Settings (Server and access) and Info, each with its own ids, since both
|
|
5052
|
+
tabs can be in the page at once."""
|
|
5053
|
+
running, disk = on_disk()
|
|
5054
|
+
now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
|
|
5055
|
+
f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
|
|
5056
|
+
# restart(): stopped and started again by serve.py's own parent process.
|
|
5057
|
+
return (f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
|
|
5058
|
+
f" for an update or {what}, whatever runs it (a service, the launcher, a container or a terminal)."
|
|
5059
|
+
" It waits for onboarding, a scheduled job and gateway calls in progress, or cuts them off if you"
|
|
5060
|
+
" insist; the page comes back in a few seconds.</p>"
|
|
5061
|
+
f"<p><button class='btn' type='button' id='{id_}'>Restart Benchwarmers</button>"
|
|
5062
|
+
f" <span id='{id_}-out' role='status'></span></p>")
|
|
5025
5063
|
|
|
5026
5064
|
|
|
5027
5065
|
RESTART = 75 # the exit code serve.py's parent reads as "start me again"
|
|
@@ -5325,6 +5363,28 @@ def endpoints_page() -> str:
|
|
|
5325
5363
|
"<div class='done' id='l-done'></div></div>")
|
|
5326
5364
|
|
|
5327
5365
|
|
|
5366
|
+
def _reply_cell(by_prompt: dict) -> str:
|
|
5367
|
+
"""Models' Reply: the daily probe's median seconds to a one-word answer (tiny),
|
|
5368
|
+
with the 18 KB prompt's (large) and tokens a second on hover. Was on Benchmarks'
|
|
5369
|
+
Performance, beside whether it answered, which is Uptime's now."""
|
|
5370
|
+
def p50(r):
|
|
5371
|
+
try:
|
|
5372
|
+
return float(r["p50_s"]) if r and int(r.get("answered") or 0) > 0 else None
|
|
5373
|
+
except (KeyError, TypeError, ValueError):
|
|
5374
|
+
return None
|
|
5375
|
+
tiny, large = p50(by_prompt.get("tiny")), p50(by_prompt.get("large"))
|
|
5376
|
+
if tiny is None and large is None:
|
|
5377
|
+
return "<td class='num muted' data-sort='999999'>—</td>"
|
|
5378
|
+
shown = tiny if tiny is not None else large
|
|
5379
|
+
why = "; ".join(b for b in (
|
|
5380
|
+
f"one-word prompt {tiny:.1f}s" if tiny is not None else "",
|
|
5381
|
+
f"18 KB prompt {large:.1f}s" if large is not None else "",
|
|
5382
|
+
f"{(by_prompt.get('large') or {}).get('tokens_per_s')} tok/s"
|
|
5383
|
+
if (by_prompt.get("large") or {}).get("tokens_per_s") else "") if b)
|
|
5384
|
+
return (f"<td class='num' data-sort='{shown:.2f}' title='median, the daily probe: {esc(why)}'>"
|
|
5385
|
+
f"{shown:.1f}s</td>")
|
|
5386
|
+
|
|
5387
|
+
|
|
5328
5388
|
def models_page() -> str:
|
|
5329
5389
|
"""Every model on the bench by the connection it comes from."""
|
|
5330
5390
|
cards = []
|
|
@@ -5337,7 +5397,12 @@ def models_page() -> str:
|
|
|
5337
5397
|
# Every model, decision models too (connections_view is chat models only,
|
|
5338
5398
|
# for what a collection may hold): they are on the bench like any other.
|
|
5339
5399
|
by: dict[str, list[str]] = {}
|
|
5340
|
-
|
|
5400
|
+
directory = model_directory()
|
|
5401
|
+
# The daily probe's latest reading per model and prompt (perf/history.csv), for Reply.
|
|
5402
|
+
speed: dict[str, dict] = {}
|
|
5403
|
+
for r in perf_rows():
|
|
5404
|
+
speed.setdefault(r["model"], {})[r.get("prompt", "")] = r
|
|
5405
|
+
for a in directory:
|
|
5341
5406
|
by.setdefault(catalog.connection_of(a), []).append(a)
|
|
5342
5407
|
for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
|
|
5343
5408
|
rows = []
|
|
@@ -5348,25 +5413,32 @@ def models_page() -> str:
|
|
|
5348
5413
|
ep = through.get(a, "?")
|
|
5349
5414
|
kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
|
|
5350
5415
|
if a in decisions else "")
|
|
5416
|
+
if directory[a].get("ad_hoc"):
|
|
5417
|
+
kind += " " + chip("ad hoc", "adhoc", "ad hoc only: never chosen by the bench on its own, only when you"
|
|
5418
|
+
" name it (a collection member, with, --model)")
|
|
5351
5419
|
rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
|
|
5352
5420
|
f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
|
|
5353
5421
|
f"<td data-sort='{esc(ep)}'>{chip(ep, 'via', f'reached through the {ep} endpoint')}</td>"
|
|
5354
5422
|
f"<td class='num' data-sort='{f.get('context_window') or 0}'>{num(f.get('context_window'))}</td>"
|
|
5355
5423
|
f"<td class='num' data-sort='{f.get('max_output') or 0}'>{num(f.get('max_output'))}</td>"
|
|
5356
5424
|
f"<td>{esc(', '.join(f.get('input_modalities') or []) or '—')}</td>"
|
|
5425
|
+
+ _reply_cell(speed.get(a, {}))
|
|
5357
5426
|
+ "".join(f"<td class='num'>{num(lim.get(k))}</td>" for k in catalog.LIMIT_KEYS) + "</tr>")
|
|
5358
5427
|
# One column plan for every card, so the cards line up under each other
|
|
5359
5428
|
# (each table sized its own columns, so they drifted from card to card).
|
|
5360
5429
|
cols = "<colgroup><col class='c-model'><col class='c-via'><col class='c-n'><col class='c-n'>" \
|
|
5361
|
-
"<col class='c-input'>" + "<col class='c-lim'>" * len(catalog.LIMIT_KEYS) + "</colgroup>"
|
|
5362
|
-
|
|
5430
|
+
"<col class='c-input'><col class='c-reply'>" + "<col class='c-lim'>" * len(catalog.LIMIT_KEYS) + "</colgroup>"
|
|
5431
|
+
# Each connection folds; the page's one box filters them all (bench.js applyFilter, data-conn).
|
|
5432
|
+
cards.append(f"<details class='panel model-card' open data-conn='{esc(c['name'])}'><summary>"
|
|
5433
|
+
f"<h3 class='chart-head'>{esc(c['name'])} · {len(c['models'])} models</h3></summary>"
|
|
5363
5434
|
f"<table class='model-cards'>{cols}<thead><tr>" + th("Model") + th("Through")
|
|
5364
5435
|
+ th("Context", True) + th("Max output", True)
|
|
5365
|
-
+ th("Input") + "".join(th(k, True) for k in catalog.LIMIT_KEYS)
|
|
5366
|
-
+ f"</tr></thead><tbody>{''.join(rows)}</tbody></table></
|
|
5436
|
+
+ th("Input") + th("Reply", True) + "".join(th(k, True) for k in catalog.LIMIT_KEYS)
|
|
5437
|
+
+ f"</tr></thead><tbody>{''.join(rows)}</tbody></table></details>")
|
|
5367
5438
|
return ("<div class='panel'><h2>Models</h2><p class='sub'>Every model on the bench by the connection it"
|
|
5368
5439
|
" comes from, with the endpoint it is reached through and what is known of its limits. A name"
|
|
5369
|
-
" opens the model.</p
|
|
5440
|
+
" opens the model.</p><div class='toolbar'><input type='search' data-filter='page'"
|
|
5441
|
+
" placeholder='Find a model or connection' aria-label='Filter models'></div></div>"
|
|
5370
5442
|
+ "".join(cards))
|
|
5371
5443
|
|
|
5372
5444
|
|
|
@@ -5604,6 +5676,7 @@ def info_page() -> str:
|
|
|
5604
5676
|
("Theme", saved_theme())]]
|
|
5605
5677
|
body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td class='mono'>{v}</td></tr>" for k, v in rows)
|
|
5606
5678
|
return (f"<div class='panel'><h2>Info</h2><table class='facts'><tbody>{body}</tbody></table>"
|
|
5679
|
+
f"{restart_block('restart-info', 'a setting')}"
|
|
5607
5680
|
"<p class='muted'>Benchwarmers · by Wendell Research · MIT licence"
|
|
5608
5681
|
" (<code>LICENSE</code>, credits in <code>NOTICE</code>). Every response carries"
|
|
5609
5682
|
" <code>X-Benchwarmers-Version</code>.</p></div>")
|
|
@@ -5668,8 +5741,7 @@ def editor() -> str:
|
|
|
5668
5741
|
+ "".join(f"<label>{k} <input type='number' min='1' data-k='{k}' placeholder='—'></label>"
|
|
5669
5742
|
for k in ("rpm", "rpd", "tpm", "tpd"))
|
|
5670
5743
|
+ "</div><div class='src' id='s-limits'></div></div>"
|
|
5671
|
-
"</div><div class='errors' id='f-errors' role='alert'></div
|
|
5672
|
-
"<div class='actions'><button class='btn' id='f-save' type='button'>Save facts</button></div>"
|
|
5744
|
+
"</div><div class='errors' id='f-errors' role='alert'></div>"
|
|
5673
5745
|
"</details>"
|
|
5674
5746
|
# How the bench calls it (serve.save_model_options): every caller, gateway, suite and probes.
|
|
5675
5747
|
"<details id='m-opts' class='facts-box'><summary>Call options</summary>"
|
|
@@ -5679,8 +5751,7 @@ def editor() -> str:
|
|
|
5679
5751
|
" makes to it. A reasoning model thinks out of the same budget and answers with an empty string"
|
|
5680
5752
|
" when it runs out, which looks like a dead model: raise it then. Blank leaves it to the"
|
|
5681
5753
|
" endpoint. <span id='co-ceiling'></span></p>"
|
|
5682
|
-
"<div class='errors' id='co-errors' role='alert'></div
|
|
5683
|
-
"<div class='actions'><button class='btn' id='co-save' type='button'>Save options</button></div>"
|
|
5754
|
+
"<div class='errors' id='co-errors' role='alert'></div>"
|
|
5684
5755
|
"</details>"
|
|
5685
5756
|
"<label class='check'><input type='checkbox' id='m-adhoc'> Ad hoc only</label>"
|
|
5686
5757
|
"<p class='rules'>Never chosen by the bench on its own: no explorer seat, no router"
|
package/ui/bench.css
CHANGED
|
@@ -293,10 +293,11 @@ td.num, th.num { text-align: right; }
|
|
|
293
293
|
table.model-cards { table-layout: fixed; min-width: 52rem; }
|
|
294
294
|
.panel:has(> table.model-cards) { overflow-x: auto; } /* scrolls on a narrow window instead of wrapping numbers */
|
|
295
295
|
table.model-cards td.num { white-space: nowrap; }
|
|
296
|
-
table.model-cards col.c-model { width:
|
|
297
|
-
table.model-cards col.c-via { width:
|
|
296
|
+
table.model-cards col.c-model { width: 23%; }
|
|
297
|
+
table.model-cards col.c-via { width: 11%; }
|
|
298
298
|
table.model-cards col.c-n { width: 10%; }
|
|
299
|
-
table.model-cards col.c-input { width:
|
|
299
|
+
table.model-cards col.c-input { width: 15%; }
|
|
300
|
+
table.model-cards col.c-reply { width: 7%; }
|
|
300
301
|
table.model-cards col.c-lim { width: 6%; }
|
|
301
302
|
table.model-cards td { overflow-wrap: anywhere; }
|
|
302
303
|
tbody tr:hover > td { background: var(--step-1); }
|
|
@@ -319,6 +320,7 @@ a.model:hover .alias { box-shadow: inset 0 -.3em 0 var(--signal); }
|
|
|
319
320
|
.chip.err { background: var(--err-ground); color: var(--err); }
|
|
320
321
|
.chip.new { background: var(--sky); color: var(--ink-deep); }
|
|
321
322
|
/* an endpoint a model is reached through: outlined, so it reads as a place, not a state */
|
|
323
|
+
.chip.adhoc { background: color-mix(in oklab, var(--sky) 55%, var(--paper-2)); color: var(--ink-deep); }
|
|
322
324
|
.chip.via { background: transparent; border: 1px solid var(--line); color: var(--ink); font-family: var(--font-mono); }
|
|
323
325
|
button.chip { font-family: inherit; border: 0; cursor: pointer; }
|
|
324
326
|
button.chip:hover { outline: 1px solid currentColor; }
|
|
@@ -334,6 +336,8 @@ td.why { white-space: nowrap; } /* Uptime's down / left out,
|
|
|
334
336
|
dialog { border: 1px solid var(--ink); border-radius: var(--radius); padding: 1.25rem 1.4rem;
|
|
335
337
|
width: min(40rem, calc(100vw - 2rem)); max-height: calc(100vh - 2rem);
|
|
336
338
|
background: var(--paper-2); color: var(--ink); box-shadow: 6px 6px 0 var(--ink); }
|
|
339
|
+
/* The collection popup holds the member table: wider, so names and chips sit on one line. */
|
|
340
|
+
dialog#c-form { width: min(54rem, calc(100vw - 2rem)); }
|
|
337
341
|
dialog::backdrop { background: color-mix(in oklab, var(--ink-deep) 45%, transparent); }
|
|
338
342
|
dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
339
343
|
:is(dialog, .form-panel) .served { color: var(--muted); font-size: .9375rem; margin: 0 0 .9rem; overflow-wrap: anywhere; }
|
|
@@ -344,11 +348,24 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
|
344
348
|
:is(dialog, .form-panel) .rules { font-size: .875rem; color: var(--muted); margin: .35rem 0 0; }
|
|
345
349
|
:is(dialog, .form-panel) label.check { display: flex; align-items: center; gap: .5rem; margin-top: 1rem; cursor: pointer; }
|
|
346
350
|
:is(dialog, .form-panel) fieldset { border: 0; padding: 0; margin: 1rem 0 0; }
|
|
347
|
-
:is(dialog, .form-panel) .members { max-height:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
351
|
+
:is(dialog, .form-panel) .members { max-height: min(30rem, 55vh); overflow: auto; border: 1px solid var(--line);
|
|
352
|
+
border-radius: var(--radius); }
|
|
353
|
+
/* A table to scan: the model (its name opens it), context, what it can do, then a
|
|
354
|
+
chip per provider it is on, each ticking that alias (bench.js drawMembers). */
|
|
355
|
+
:is(dialog, .form-panel) .members :is(.m-row, .m-head) { display: grid; gap: .2rem .8rem; align-items: center;
|
|
356
|
+
grid-template-columns: minmax(9rem, 1.4fr) 3.6rem minmax(0, 1fr) minmax(0, 1.3fr); padding: .4rem .6rem; }
|
|
357
|
+
:is(dialog, .form-panel) .members .m-head { position: sticky; top: 0; z-index: 1; background: var(--paper-2);
|
|
358
|
+
border-bottom: 1px solid var(--ink); font-size: .75rem; font-weight: 600; color: var(--muted); }
|
|
359
|
+
:is(dialog, .form-panel) .members .m-row { border-bottom: 1px solid var(--line); }
|
|
360
|
+
:is(dialog, .form-panel) .members .m-row:nth-child(odd) { background: color-mix(in oklab, var(--band) 45%, transparent); }
|
|
361
|
+
:is(dialog, .form-panel) .members .m-row:has(input:checked) { box-shadow: inset 3px 0 0 var(--bench-seat, var(--brand)); }
|
|
362
|
+
:is(dialog, .form-panel) .members .m-name a.model { color: var(--ink); text-decoration: none; font-size: .875rem; }
|
|
363
|
+
:is(dialog, .form-panel) .members .m-name a.model:hover { text-decoration: underline; }
|
|
364
|
+
:is(dialog, .form-panel) .members .m-ctx { font-family: var(--font-mono); font-size: .8125rem; color: var(--muted); text-align: right; }
|
|
365
|
+
:is(dialog, .form-panel) .members .m-can { display: flex; flex-wrap: wrap; gap: .2rem; }
|
|
366
|
+
:is(dialog, .form-panel) .members .m-can .chip { font-size: .6875rem; padding: 0 .4rem; }
|
|
367
|
+
#c-form .members-head { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
|
|
368
|
+
#c-form .members-head .field { margin-right: auto; }
|
|
352
369
|
:is(dialog, .form-panel) .members .m-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .5rem;
|
|
353
370
|
font-size: .875rem; overflow-wrap: anywhere; }
|
|
354
371
|
:is(dialog, .form-panel) .members .m-facts { color: var(--muted); font-size: .8125rem; }
|
|
@@ -380,6 +397,8 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
|
380
397
|
:is(dialog, .form-panel) .profile .src { font-size: .75rem; color: var(--muted); }
|
|
381
398
|
:is(dialog, .form-panel) .settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 1rem; }
|
|
382
399
|
@media (max-width: 40rem) { :is(dialog, .form-panel) :is(.settings, .members .m-row) { grid-template-columns: minmax(0, 1fr); }
|
|
400
|
+
:is(dialog, .form-panel) .members .m-head { display: none; }
|
|
401
|
+
:is(dialog, .form-panel) .members .m-ctx { text-align: left; }
|
|
383
402
|
:is(dialog, .form-panel) .members .m-routes { justify-content: flex-start; } }
|
|
384
403
|
:is(dialog, .form-panel) fieldset label.check { margin-top: .4rem; flex-wrap: wrap; }
|
|
385
404
|
:is(dialog, .form-panel) fieldset label.check .muted { font-size: .875rem; }
|
|
@@ -519,6 +538,9 @@ pre.log.answer { max-height: 40vh; }
|
|
|
519
538
|
.chart { margin: .5rem 0 .75rem; overflow-x: auto; }
|
|
520
539
|
.chart.muted { font-size: .875rem; }
|
|
521
540
|
h3.chart-head { font-size: 1rem; margin: 1rem 0 .2rem; }
|
|
541
|
+
.chart-label { font-size: .875rem; font-weight: 600; margin: .9rem 0 .1rem; }
|
|
542
|
+
/* Models: each connection a fold, its heading the summary */
|
|
543
|
+
.model-card > summary h3.chart-head { display: inline; margin: 0; }
|
|
522
544
|
/* the Collections tab: the dialog's form, kept to a readable width */
|
|
523
545
|
.panel.form-panel > :not(#c-list, table) { max-width: 46rem; }
|
|
524
546
|
/* a list of things you open (collections), with its New button above */
|