@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 CHANGED
@@ -1 +1 @@
1
- 0.6.1
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
- served = (_registered_names().get(alias)
152
- or (_json(DATA / "config" / "decision-models.json").get(alias) or {}).get("served")
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
- name = endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
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.1",
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",
@@ -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.split("//", 1)[-1].split("/", 1)[0].split(":", 1)[0]
77
- if host.replace(".", "").isdigit():
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
@@ -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
- """Reliability over the last 14 days of probes, and speed on real tasks
2904
- against how much a model finds, for the Performance panel of Benchmarks."""
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 {"reliability": [{"model": m, "outcome": o, "count": n} for (m, o), n in sorted(outcomes.items())],
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
- return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS],
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 sorted((res.get("models") or {}).items()) for s, r in by.items()
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>Calibration</dt><dd>The chart: answers grouped by the probability the model gave its answer
3631
- (x), against how often those answers were right (y). On the diagonal, its 80% answers come true
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>Found share minus false-alarm share, best at the top.</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"] / r["judged"]):
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 number beside a name</dt><dd>How many were judged. 3 of 3 is weaker evidence than 40 of 50.</dd>
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.</p>"
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>Each probe of the last 14 days, by what came back: an answer, a
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.</dd>
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'>Did it answer? Last 14 days of probes</h3>"
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-provider' aria-label='Provider' aria-controls='c-members'>"
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
- "<div class='pick'><label class='check'><input type='radio' name='x-scope' value='settings' checked"
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></div>"
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
- # restart(): stopped and started again by serve.py's own parent process.
5018
- f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
5019
- " for an update or a setting"
5020
- " above, whatever runs it (a service, the launcher, a container or a terminal). It waits for"
5021
- " onboarding, a scheduled job and gateway calls in progress, or cuts them off if you insist; the"
5022
- " page comes back in a few seconds.</p>"
5023
- "<p><button class='btn' type='button' id='restart'>Restart Benchwarmers</button>"
5024
- " <span id='restart-out' role='status'></span></p></div>")
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
- for a in model_directory():
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
- cards.append(f"<div class='panel'><h3 class='chart-head'>{esc(c['name'])} · {len(c['models'])} models</h3>"
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></div>")
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></div>"
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><div class='done' id='f-done'></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><div class='done' id='co-done'></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: 24%; }
297
- table.model-cards col.c-via { width: 13%; }
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: 19%; }
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: 18rem; overflow: auto; border-top: 1px solid var(--line); }
348
- /* One row per model: its name and what it holds, then a chip per provider it
349
- is on, each ticking that alias (bench.js fillForm). */
350
- :is(dialog, .form-panel) .members .m-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .2rem 1rem;
351
- align-items: center; padding: .4rem 0; border-bottom: 1px solid var(--line); }
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 */