@wendellresearch-ai/benchwarmers 0.6.3 → 0.6.4

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/AGENTS.md CHANGED
@@ -124,7 +124,9 @@ in `bench.skipped`. One the catalog knows nothing about sits once its model
124
124
  popup ticks Vision / OCR.
125
125
  After the person checks an answer against the real work, record a verdict
126
126
  (dashboard, or `POST /bench/verdict`); verdicts choose the next panels. See
127
- GATEWAY.md for the whole contract.
127
+ GATEWAY.md for the whole contract. To set an app up on the gateway (opencode,
128
+ VS Code, Codex, Zed, Continue, Cline, Kilo Code, Claude Code, goose, chat
129
+ apps), CLIENTS.md has each one's configuration, file by file.
128
130
 
129
131
  ## Beyond this machine
130
132
 
package/BENCHMARKS.md CHANGED
@@ -70,7 +70,7 @@ earlier scores on the same tasks, over the last 30 days (or 60).
70
70
 
71
71
  Whether the provider was there when the bench called it, from the calls the
72
72
  bench makes anyway: one cell a day, by provider and then by model, over the
73
- last 7 days (or 30). At a glance has each model's 7 days too.
73
+ last 7 days (or 30).
74
74
 
75
75
  - **Down**: the provider was busy or overloaded (a 5xx, high demand, a shared
76
76
  pool's 429), unreachable (a broken stream, no reply), timed out (the
package/README.md CHANGED
@@ -419,6 +419,8 @@ collections and MCP server, with a Copy button: opencode, VS Code chat, Codex
419
419
  CLI, Zed, Continue, Cline and Kilo Code, Claude Code, goose, and chat apps
420
420
  (LobeHub, AnythingLLM, Cherry Studio, Chatbox, LibreChat). Where keys are
421
421
  asked for, it makes one and fills it in, never into a file that may sync.
422
+ The same set-ups are in [CLIENTS.md](CLIENTS.md), with a fresh install's
423
+ address and collections, for reading here or handing to an agent.
422
424
 
423
425
  <!-- The MCP Registry's proof that the PyPI package is this server (server.json):
424
426
  mcp-name: io.github.WendellResearch-AI/benchwarmers -->
package/USAGE.md CHANGED
@@ -25,15 +25,14 @@ coding agent doing it with you); the gateway's whole contract is in
25
25
 
26
26
  | Group | Page | What it is for |
27
27
  | --- | --- | --- |
28
- | Observe | **At a glance** | Every model on the bench: whether it works right now, its uptime over 7 days, its role, and its connection. Click a name for its popup: role, alias, facts, options, retire. |
28
+ | Observe | **Benchmarks** | The home page. How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, each model against its own earlier scores, uptime, speed, and the decision models. Each report shares as an image or its table. |
29
29
  | | **Action needed** | What will not fix itself, by the connection it comes from, with Retry and Retire on each model. |
30
- | | **Benchmarks** | How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, each model against its own earlier scores, uptime, speed, and the decision models. Each report shares as an image or its table. |
31
30
  | | **Quota management** | A tile per provider: what is left, when it resets, and which of its models are resting. Then the week's resets, and what the bench sent. |
32
31
  | | **Logs** | Gateway calls (who sat and how it went, never the prompt), changes made from the dashboard, the scheduler, and onboarding runs. |
33
32
  | Gateway | **Endpoints** | Where models are served from. Add an endpoint (a provider's sign-in, or any OpenAI-compatible API key), change a key, and see what each one serves. A model server running on this machine, at its usual port or any other its process is listening on (LM Studio, Jan, llama.cpp, vLLM and the like), is offered under Found on this machine: Add opens the dialog with it filled in, to test and save. Also your own app registrations and rate limits per connection. |
34
- | | **Models** | Every model on the bench, by the connection it comes from. |
33
+ | | **Models** | Every model on the bench, by the connection it comes from: what it holds, its reply time and its limits. One search above every table, each table folds, and Collapse all / Expand all. Click a name for its popup: role, alias, facts, options, retire. |
35
34
  | | **Collections** | The named panels your apps ask for, and the gateway's settings. |
36
- | | **Clients** | Each popular app's set-up (opencode, VS Code, Codex, Zed, Continue, Cline, Kilo Code, Claude Code, goose, chat apps), filled in from this install, with Copy and a gateway key made on the spot. |
35
+ | | **Clients** | Each popular app's set-up (opencode, VS Code, Codex, Zed, Continue, Cline, Kilo Code, Claude Code, goose, chat apps), filled in from this install, with Copy and a gateway key made on the spot. [CLIENTS.md](CLIENTS.md) has the same, for a fresh install. |
37
36
  | | **Discover** | Models worth adding, from public catalogs, with the ways this install can reach each one. |
38
37
  | Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app, export and import, how the install is reached (shown only), and Restart. |
39
38
  | | **Info** | The source repository, the version (linking its release), Python, data folder and counts. |
@@ -52,8 +51,7 @@ clears the alert) and Retire. A group of two or more has Retry all and Retire
52
51
  all; a connection held because every model on it fails says so above its
53
52
  models, with any link the provider gave. Automatic retirements from the last
54
53
  week are folded at the end, each with Restore. Its count sits beside it in the
55
- sidebar, and At a glance opens on one line saying what is waiting and how
56
- soon. An alert whose models have all been retired goes at once. A model that
54
+ sidebar. An alert whose models have all been retired goes at once. A model that
57
55
  is out of quota is not counting down: days the bench held its calls back until
58
56
  a stated reset, an account's shared free quota was spent, or the provider said
59
57
  the model's free quota is used up, pause its clock, and while it is still out
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.3
1
+ 0.6.4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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/check.py CHANGED
@@ -49,6 +49,7 @@ CHECKS = [
49
49
  ("scripts/onboard.py", ["--selftest"], False),
50
50
  ("scripts/export_check.py", ["--selftest"], False),
51
51
  ("scripts/notice.py", ["--check"], False),
52
+ ("scripts/clients_doc.py", ["--check"], False), # CLIENTS.md is what the Clients page shows
52
53
  ("scripts/check_themes.py", [], False),
53
54
  ("scripts/test_owui.py", [], False),
54
55
  ("scripts/test_hubs.py", [], False),
@@ -0,0 +1,98 @@
1
+ """Write CLIENTS.md from the dashboard's Clients page, so each app's set-up is in
2
+ the repository for people and agents to read, and never drifts from the page.
3
+
4
+ python scripts/clients_doc.py # write CLIENTS.md
5
+ python scripts/clients_doc.py --check # fail if CLIENTS.md is not what it would write (check.py)
6
+
7
+ One source: serve.client_setups, the function the Clients page draws. The page
8
+ fills in this install (its address, its collections, a key made on the spot);
9
+ here they are the defaults a fresh install has: the server at 127.0.0.1:8122,
10
+ collections `core` and `fast`, and every call needing a gateway key, read from
11
+ BENCHWARMERS_KEY. The page's HTML (code, links) becomes Markdown.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import os
18
+ import pathlib
19
+ import re
20
+ import sys
21
+ import tempfile
22
+
23
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
24
+ OUT = ROOT / "CLIENTS.md"
25
+ COLLECTIONS = [("core", None, False), ("fast", None, False)]
26
+
27
+
28
+ def md(text: str) -> str:
29
+ """The page's inline HTML as Markdown: code, links, line breaks; any other tag dropped."""
30
+ text = re.sub(r"<code>(.*?)</code>", lambda m: "`" + html.unescape(m.group(1)) + "`", text)
31
+ text = re.sub(r"<a [^>]*href='([^']*)'[^>]*>(.*?)</a>", r"[\2](\1)", text)
32
+ text = re.sub(r"<br\s*/?>", " \n", text)
33
+ return html.unescape(re.sub(r"<[^>]+>", "", text))
34
+
35
+
36
+ def fence(lang: str, text: str) -> str:
37
+ tick = "````" if "```" in text else "```"
38
+ return f"{tick}{lang}\n{text}\n{tick}"
39
+
40
+
41
+ def render() -> str:
42
+ # serve reads the data folder at import: an empty temp one, never the install's.
43
+ os.environ.setdefault("BENCHWARMERS_HOME", tempfile.mkdtemp(prefix="bench-clients-doc-"))
44
+ sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
45
+ import serve
46
+ root = serve.DEFAULT_SERVER
47
+ out = ["# Setting up clients",
48
+ "",
49
+ "Every app Benchwarmers has a ready set-up for, as the dashboard's **Clients** page shows it,"
50
+ " written out for people and for agents setting an app up. The page fills in your own install:"
51
+ " its address, your collections and a gateway key made on the spot. Here they are a fresh"
52
+ f" install's: the server at `{root}`, the collections `core` and `fast`, and a gateway key"
53
+ f" read from `{serve.KEY_ENV}`. Replace them with yours.",
54
+ "",
55
+ "Agents get a collection in route mode (one member with its tools, `bench/<collection>:route`);"
56
+ " chat windows a merged answer (`:merged`); the MCP server the panel itself, as ask and verdict"
57
+ f" tools. Gateway `{root}/v1` · MCP server `{root}/mcp`.",
58
+ "",
59
+ f"_Generated from `serve.client_setups` by `scripts/clients_doc.py`; each app's format follows its"
60
+ f" own docs as read on {serve.CLIENTS_READ}. Do not edit by hand: run the script._",
61
+ "",
62
+ "## The gateway key",
63
+ "",
64
+ "When every call needs a key (`BENCHWARMERS_REQUIRE_KEYS=1`, or an admin token), make one on"
65
+ " Settings or the Clients page. Most set-ups read it from"
66
+ f" `{serve.KEY_ENV}`:",
67
+ ""]
68
+ for s in serve._env_snips(serve.KEY_ENV):
69
+ out += [f"**{s['label']}**", "", fence(s["lang"], s["text"]), ""]
70
+ setups = serve.client_setups(root, COLLECTIONS, True)
71
+ out += ["## Contents", ""] + [f"- [{c['name']}](#{c['id']})" for c in setups] + [""]
72
+ for c in setups:
73
+ out += [f"<a id='{c['id']}'></a>", "", f"## {c['name']}", "", md(c["gets"]), "",
74
+ f"**Where it goes:** {md(c['where'])}", ""]
75
+ for s in c["blocks"]:
76
+ out += [f"**{s['label']}**", "", fence(s["lang"], s["text"]), ""]
77
+ docs = ", ".join(f"[{t}]({u})" for u, t in c["docs"])
78
+ out += [f"**Watch for:** {md(c['watch'])}", "",
79
+ f"Written from {docs}, read {serve.CLIENTS_READ}"
80
+ + (f": {md(c['docs_note'])}" if c.get("docs_note") else "."), ""]
81
+ return "\n".join(out).rstrip() + "\n"
82
+
83
+
84
+ def main() -> int:
85
+ text = render()
86
+ if "--check" in sys.argv:
87
+ if not OUT.exists() or OUT.read_text(encoding="utf-8") != text:
88
+ print("CLIENTS.md is not what serve.client_setups gives: run python scripts/clients_doc.py")
89
+ return 1
90
+ print("CLIENTS.md matches the Clients page")
91
+ return 0
92
+ OUT.write_text(text, encoding="utf-8", newline="\n")
93
+ print(f"wrote {OUT.name}")
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ sys.exit(main())
package/serve.py CHANGED
@@ -2254,27 +2254,6 @@ def open_count(groups: dict) -> int:
2254
2254
  return sum(len(g["rows"]) + len(g["held"]) for g in groups.values())
2255
2255
 
2256
2256
 
2257
- def and_list(parts: list[str]) -> str:
2258
- return " and ".join(parts) if len(parts) < 3 else ", ".join(parts[:-1]) + " and " + parts[-1]
2259
-
2260
-
2261
- def actions_line(groups: dict) -> str:
2262
- """At a glance's one line about Action needed, when anything is open:
2263
- what is waiting and how soon, and the way to its page."""
2264
- rows = [r for g in groups.values() for r in g["rows"]]
2265
- held = sum(len(g["held"]) for g in groups.values())
2266
- if not rows and not held:
2267
- return ""
2268
- models = sum(1 for r in rows if r["alias"])
2269
- what = and_list([f"{n} {word}{'s' * (n != 1)}" for n, word in
2270
- ((models, "model"), (held, "connection"), (len(rows) - models, "notice")) if n])
2271
- soon = min(r["days"] for r in rows) if rows else 99
2272
- many = sum(r["days"] == soon for r in rows)
2273
- when = (f"; {many} retire{'s' * (many == 1)} in {soon} day{'s' * (soon != 1)}" if soon < 99 else "")
2274
- return (f"<p class='a-line'><strong>Action needed</strong> <span>{what}{when}.</span>"
2275
- " <button type='button' class='linkish' data-go='needs'>Review →</button></p>")
2276
-
2277
-
2278
2257
  def actions_page(groups: dict, auto: list) -> str:
2279
2258
  """Action needed, its own page since 2026-11-02: it sat above every page
2280
2259
  and grew to a screen and more of red. Grouped by connection, because
@@ -2373,167 +2352,6 @@ def price_of(alias: str, served: str, f: dict) -> str:
2373
2352
  return "paid" if sources.is_free(f.get("cost")) is False else "unknown"
2374
2353
 
2375
2354
 
2376
- def facet(name: str, label: str, values) -> str:
2377
- """A filter by one column's value, beside a panel's search box (bench.js
2378
- matches it against each row's data-<name>)."""
2379
- opts = "".join(f"<option value='{esc(v)}'>{esc(v)}</option>" for v in sorted(set(values) - {""}))
2380
- return (f"<label>{esc(label)} <select data-facet='{esc(name)}'><option value=''>any</option>"
2381
- f"{opts}</select></label>")
2382
-
2383
-
2384
- def facts_cells(alias: str, f: dict | None = None) -> str:
2385
- """Context window and capabilities, from metrics/catalog.py; a value you
2386
- entered is marked, and an unknown one says so rather than looking empty."""
2387
- f = f or catalog.info(alias)
2388
- ctx = f.get("context_window")
2389
- src = f["sources"].get("context_window", "")
2390
- ctx_cell = (f"<td class='num' data-sort='{ctx}' title='{esc(src)}'>"
2391
- f"{ctx // 1000:,}k{'*' if src == 'you' else ''}</td>" if ctx
2392
- else "<td class='num muted' data-sort='-1' title='unknown: fill it in the model popup'>?</td>")
2393
- caps = [CAP_LABELS[c] for c in catalog.CAPS if f.get(c)]
2394
- return ctx_cell + (f"<td class='caps'>{' '.join(chip(c, '') for c in caps)}</td>" if caps
2395
- else "<td class='muted'>—</td>")
2396
-
2397
-
2398
- def roster() -> str:
2399
- """Every model on one table: its state, what it has done, what it scored.
2400
-
2401
- Replaces a grid of offload cards that answered only "what was it used
2402
- for". Status first, because the first question on opening this page is
2403
- whether anything is broken; real work second; benchmarks after."""
2404
- try:
2405
- from offload import summary
2406
- work = summary()
2407
- except Exception: # noqa: BLE001
2408
- work = {}
2409
- try:
2410
- import auto_retire
2411
- rows, answers, _ = auto_retire.evidence()
2412
- except Exception: # noqa: BLE001
2413
- rows, answers = [], {}
2414
- last = dict(answers)
2415
- for r in rows:
2416
- if r["answered"]:
2417
- last[r["alias"]] = max(last.get(r["alias"], r["date"]), r["date"])
2418
- suite: dict[str, list[int]] = {}
2419
- for (_task, model), hit in suite_scores().items():
2420
- if (m := re.match(r"(\d+)/(\d+)", hit["found"])):
2421
- got = suite.setdefault(model, [0, 0, 0])
2422
- got[0] += int(m.group(1)); got[1] += int(m.group(2)); got[2] += 1
2423
- tiny = {r["model"]: float(r["p50_s"]) for r in perf_rows()
2424
- if r["prompt"] == "tiny" and int(r.get("answered") or 0) > 0}
2425
- status = model_status()
2426
- new = recently_onboarded()
2427
- stuck = onboard_issues(status)
2428
- directory = model_directory()
2429
- severity = {"err": 0, "warn": 1, "": 2, "ok": 3}
2430
-
2431
- up = uptime_rows()["models"]
2432
- body = ["<table><thead><tr>", th("Model"), th("Status"), th("Uptime 7d", True), th("Role"),
2433
- th("Context", True), th("Can"),
2434
- th("Offloads", True), th("Helped"), th("Suite", True),
2435
- th("Reply time", True), th("Last answer"), "</tr></thead><tbody>"]
2436
- # The model browser: each row says its connection, status, role and price
2437
- # for the toolbar's filters, and the filters offer what the rows hold.
2438
- offered: dict[str, list[str]] = {"connection": [], "status": [], "role": [], "price": []}
2439
-
2440
- def browse(**values) -> str:
2441
- for k, v in values.items():
2442
- offered[k].append(v)
2443
- return "".join(f" data-{k}='{esc(v)}'" for k, v in values.items())
2444
- for alias in sorted(status, key=lambda a: (-work.get(a, {}).get("offloads", 0), a)):
2445
- label, css, why = status[alias]
2446
- f = catalog.info(alias)
2447
- served = directory.get(alias, {}).get("served") or retired().get(alias, {}).get("served", "")
2448
- w = work.get(alias, {})
2449
- judged, useful = w.get("judged", 0), w.get("useful", 0)
2450
- if judged:
2451
- rate = useful / judged
2452
- helped = (f"<span class='helped'><span class='meter'><span"
2453
- f"{' class=low' if rate < .5 else ''} style='width:{max(4, round(rate * 100))}%'>"
2454
- f"</span></span><span class='mono'>{useful}/{judged}</span></span>")
2455
- else:
2456
- rate, helped = -1, "<span class='muted'>not judged</span>"
2457
- s = suite.get(alias)
2458
- suite_cell = (f"<td class='num' data-sort='{s[0] / s[1]:.3f}'"
2459
- f" title='{s[2]} task{'s' * (s[2] != 1)}'>{s[0]}/{s[1]}</td>"
2460
- if s and s[1] else "<td class='num muted' data-sort='-1'>—</td>")
2461
- p50 = tiny.get(alias)
2462
- seen = last.get(alias)
2463
- role = directory.get(alias, {}).get("role") or retired().get(alias, {}).get("role", "")
2464
- conn = catalog.connection_of(alias)
2465
- if conn == "?": # retired: no registration left to say, but its record does
2466
- conn = retired().get(alias, {}).get("tag", "").split("/")[0] or "unknown"
2467
- attrs = browse(connection=conn, status=re.sub(r" in \d+d$", "", label),
2468
- role=role, price=price_of(alias, served, f))
2469
- body.append(
2470
- f"{tr(alias, attrs)}<td>{named(alias)}</td>"
2471
- f"<td data-sort='{severity[css]}'>{chip(label, css, why)}"
2472
- + (" " + chip("new", "new", "onboarded this week") if alias in new else "")
2473
- + (" " + chip("onboard stopped", stuck[alias]["css"],
2474
- f"onboarding stopped at the {stuck[alias].get('step', 'probe')}:"
2475
- f" {stuck[alias]['label']}. Open the model for the error.")
2476
- if alias in stuck else "")
2477
- + "</td>" + uptime_cell(up.get(alias)) + f"<td>{esc(role)}</td>{facts_cells(alias, f)}"
2478
- f"<td class='num'>{w.get('offloads', 0)}</td>"
2479
- f"<td data-sort='{rate:.3f}'>{helped}</td>{suite_cell}"
2480
- + (f"<td class='num' data-sort='{p50}'>{p50:.1f}s</td>" if p50 is not None
2481
- else "<td class='num muted' data-sort='99999'>—</td>")
2482
- + (f"<td class='mono' data-sort='{seen}'>{seen}</td>" if seen
2483
- else "<td class='muted' data-sort='0'>never</td>")
2484
- + "</tr>")
2485
- # Decision models are not in the llm yaml, so no probe, suite or reply time
2486
- # applies; the role column says what they are and what they answer.
2487
- for alias, d in sorted(decide.load().items()):
2488
- w = work.get(alias, {})
2489
- body.append(
2490
- "<tr" + browse(connection=d.get("via", ""), status="no probe", role="decision",
2491
- price="free" if ":free" in d["served"] else "unknown") + ">"
2492
- f"<td>{named(alias)}</td>"
2493
- "<td data-sort='2'><span class='muted' title='decision models have no"
2494
- " availability probe yet'>—</span>"
2495
- + (" " + chip("new", "new", "onboarded this week") if d.get("onboarded", "") >=
2496
- (datetime.date.today() - datetime.timedelta(days=7)).isoformat() else "")
2497
- + "</td>" + uptime_cell(up.get(alias)) + f"<td>decision · {esc(', '.join(d['question_types']))}</td>"
2498
- "<td class='num muted' data-sort='-1'>—</td><td class='muted'>—</td>"
2499
- f"<td class='num'>{w.get('offloads', 0)}</td>"
2500
- "<td data-sort='-1'><span class='muted'>not judged</span></td>"
2501
- "<td class='num muted' data-sort='-1'>—</td><td class='num muted' data-sort='99999'>—</td>"
2502
- "<td class='muted' data-sort='0'>—</td></tr>")
2503
- body.append("</tbody></table>")
2504
- key = """<dl>
2505
- <dt>Status</dt><dd>From the latest probe. <em>healthy</em> answered;
2506
- <em>throttled</em> and <em>slow</em> are rate limits and timeouts, which
2507
- never retire a model inside 7 days; <em>retiring in Nd</em> is the
2508
- countdown before the bench retires it on its own. Hover a chip for the
2509
- provider's own words.</dd>
2510
- <dt>Uptime 7d</dt><dd>Of its calls in the last 7 days that say anything about the provider,
2511
- the share answered: busy, unreachable and timed out count against it; our own quota,
2512
- pacing and setup problems are left out. Benchmarks has it a day at a time.</dd>
2513
- <dt>Offloads, Helped</dt><dd>Real work handed over, and how much of what
2514
- was judged actually helped. Benchmarks never count here.</dd>
2515
- <dt>Suite</dt><dd>Verified findings located across the internal tasks:
2516
- the tier that decides routing.</dd>
2517
- <dt>Reply time</dt><dd>Median seconds to answer a one-word prompt: the
2518
- serving path's overhead, not the model's worth.</dd>
2519
- <dt>Last answer</dt><dd>The latest real answer from any source: probe,
2520
- suite or a useful offload. Seven days without one retires a model.</dd>
2521
- <dt>decision</dt><dd>A decision model: it answers typed questions (yes/no,
2522
- choice, score) with probabilities through a Decisions API, never chat.
2523
- The role column lists the question types it takes. No probe, suite or
2524
- reply time applies to it.</dd>
2525
- <dt>Filters</dt><dd>Connection is where a model's calls go (its Open WebUI
2526
- tag, or its own endpoint). Price: <em>free</em> for a <code>:free</code>
2527
- slug, a local model, a list price of 0 or a free-tier listing;
2528
- <em>paid</em> for a subscription or a list price; else unknown. Combine
2529
- them with the search box.</dd>
2530
- </dl>"""
2531
- facets = "".join(facet(k, k.title(), v) for k, v in offered.items())
2532
- return card("At a glance", "Every model on the bench: whether it works right "
2533
- "now, what it has been used for, and what it scored.",
2534
- "".join(body), key, facets)
2535
-
2536
-
2537
2355
  def on_bench() -> set[str]:
2538
2356
  """Aliases registered now: what the charts draw."""
2539
2357
  return set(registered().values())
@@ -3378,15 +3196,6 @@ def _uptime_window(cells: list, days: int) -> dict:
3378
3196
  "median": seconds[len(seconds) // 2] if seconds else None}
3379
3197
 
3380
3198
 
3381
- def uptime_cell(cells: list | None, days: int = UPTIME_DAYS[0]) -> str:
3382
- """At a glance's Uptime column: answered of counted over the last `days`."""
3383
- t = _uptime_window(cells or [], days)
3384
- if t["pct"] is None:
3385
- return "<td class='num muted' data-sort='-1' title='no counted calls'>—</td>"
3386
- return (f"<td class='num{' err' if t['pct'] < .8 else ''}' data-sort='{t['pct']:.4f}'"
3387
- f" title='{t['up']} of {t['up'] + t['down']} answered'>{t['pct'] * 100:.1f}%</td>")
3388
-
3389
-
3390
3199
  def _why(kinds: dict, which) -> str:
3391
3200
  return ", ".join(f"{k} {n}" for k, n in sorted(kinds.items(), key=lambda kv: -kv[1]) if which(k))
3392
3201
 
@@ -3925,12 +3734,12 @@ def favicon(theme: str) -> str:
3925
3734
  return f"<link rel='icon' type='image/svg+xml' href='data:image/svg+xml,{quote(svg)}'>"
3926
3735
 
3927
3736
 
3928
- def render(theme: str | None = None, shown: str = "glance") -> str:
3737
+ def render(theme: str | None = None, shown: str = "suite") -> str:
3929
3738
  with locked.snapshot(): # each file read once a render (metrics/locked.py)
3930
3739
  return _page(theme, shown)
3931
3740
 
3932
3741
 
3933
- def _page(theme: str | None, shown: str = "glance") -> str:
3742
+ def _page(theme: str | None, shown: str = "suite") -> str:
3934
3743
  theme = theme if theme in themes() else saved_theme() # a ?theme preview, else the saved one
3935
3744
  return (
3936
3745
  f"<!doctype html><html lang='en' data-theme='{esc(theme)}'><meta charset='utf-8'>"
@@ -4406,9 +4215,10 @@ def collections_tab() -> str:
4406
4215
  # A grouped sidebar since 2026-10-19 (RELEASE-PLAN "The dashboard"): eight
4407
4216
  # pages were too many for a row of tabs. Collections holds the gateway's
4408
4217
  # requests under the collections they ran; Benchmarks holds Performance.
4409
- SECTIONS = (("Observe", (("glance", "At a glance", "roster"),
4218
+ # At a glance (every model's state and uptime) was retired 2026-11-10: Benchmarks,
4219
+ # Uptime and Models said it all again. Benchmarks is the home page.
4220
+ SECTIONS = (("Observe", (("suite", "Benchmarks", "benchmarks_tab"),
4410
4221
  ("needs", "Action needed", "actions_page"),
4411
- ("suite", "Benchmarks", "benchmarks_tab"),
4412
4222
  ("quotas", "Quota management", "quotas_page"),
4413
4223
  ("logs", "Logs", "logs_page"))),
4414
4224
  ("Gateway", (("endpoints", "Endpoints", "endpoints_page"),
@@ -4433,7 +4243,9 @@ def collections_and_gateway() -> str:
4433
4243
  # names an environment variable, or the app asks, or (an MCP header in Zed,
4434
4244
  # Cline or goose, which take no variable) KEY_HOLDER stays for the reader.
4435
4245
  # Only blocks marked `key` (shell lines, fields typed into an app) get the key
4436
- # made on the page, swapped in for KEY_HOLDER by bench.js.
4246
+ # made on the page, swapped in for KEY_HOLDER by bench.js. CLIENTS.md is written
4247
+ # from client_setups (scripts/clients_doc.py): after a change here, run it, as
4248
+ # check.py fails while the file differs.
4437
4249
  KEY_HOLDER = "<your gateway key>"
4438
4250
  KEY_ENV = "BENCHWARMERS_KEY" # the plugin's and the CLI's name for it too
4439
4251
  CLIENTS_READ = "2026-11-08"
@@ -4729,20 +4541,20 @@ def _snip_html(group: str, i: int, s: dict) -> str:
4729
4541
  def pane(key: str, actions: tuple | None = None) -> str:
4730
4542
  """One tab's page, as its section holds it. The page renders the tab shown
4731
4543
  (tabs); bench.js fetches the others from GET /api/pane when first opened."""
4732
- if key in ("needs", "glance"):
4544
+ if key == "needs":
4733
4545
  groups, auto = actions or open_actions()
4734
- return actions_page(groups, auto) if key == "needs" else actions_line(groups) + roster()
4546
+ return actions_page(groups, auto)
4735
4547
  return globals()[next(fn for k, _, fn in TABS if k == key)]()
4736
4548
 
4737
4549
 
4738
- def tabs(shown: str = "glance") -> str:
4739
- # Read once a render: Action needed's count in the sidebar, and its page or
4740
- # At a glance's line about it when one of those is shown. Only the tab
4741
- # shown is rendered (the bench_tab cookie, set by bench.js): rendering every
4742
- # one took seconds. The rest are empty until opened (data-lazy).
4550
+ def tabs(shown: str = "suite") -> str:
4551
+ # Read once a render: Action needed's count in the sidebar, and its page when
4552
+ # shown. Only the tab shown is rendered (the bench_tab cookie, set by bench.js):
4553
+ # rendering every one took seconds. The rest are empty until opened (data-lazy).
4554
+ # A tab that is gone (a cookie still saying glance) shows the home page.
4743
4555
  actions = open_actions()
4744
4556
  count = {"needs": open_count(actions[0])}
4745
- shown = shown if any(k == shown for k, _, _ in TABS) else "glance"
4557
+ shown = shown if any(k == shown for k, _, _ in TABS) else "suite"
4746
4558
 
4747
4559
  def button(key: str, label: str) -> str:
4748
4560
  n = count.get(key)
@@ -5438,7 +5250,11 @@ def models_page() -> str:
5438
5250
  return ("<div class='panel'><h2>Models</h2><p class='sub'>Every model on the bench by the connection it"
5439
5251
  " comes from, with the endpoint it is reached through and what is known of its limits. A name"
5440
5252
  " 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>"
5253
+ " placeholder='Find a model or connection' aria-label='Filter models'>"
5254
+ # One labelled toggle, as accordions do it (GOV.UK's "Show all sections"): Collapse all
5255
+ # while every table is open, else Expand all (bench.js foldButtons keeps it right).
5256
+ "<button class='fold-all' type='button' data-fold-all='close' aria-expanded='true'>"
5257
+ "<span class='fold-chev' aria-hidden='true'></span>Collapse all</button></div></div>"
5442
5258
  + "".join(cards))
5443
5259
 
5444
5260
 
@@ -5615,11 +5431,16 @@ def logs_page() -> str:
5615
5431
  for p in sorted((DATA / "runs").glob("onboard-*.log"), reverse=True)[:30])
5616
5432
 
5617
5433
  def section(title, body, n, open_=False):
5618
- return (f"<details class='panel'{' open' if open_ else ''}><summary><b>{esc(title)}</b>"
5619
- f" <span class='muted'>· {n}</span></summary>{body}</details>")
5434
+ # CSV: this log alone, its own columns, the filter applied (bench.js); for a spreadsheet.
5435
+ return (f"<details class='panel'{' open' if open_ else ''} data-log='{esc(title)}'><summary><b>{esc(title)}</b>"
5436
+ f" <span class='muted'>· {n}</span> <button class='btn small' type='button' data-csv"
5437
+ f" title='Save this log, as filtered, as a CSV file'>CSV</button></summary>{body}</details>")
5620
5438
  return (
5621
- "<div class='panel'><input data-filter='page' placeholder='Filter every log on this page'"
5622
- " aria-label='Filter logs'></div>"
5439
+ "<div class='panel'><div class='toolbar'><input data-filter='page' placeholder='Filter every log on this page'"
5440
+ " aria-label='Filter logs'>"
5441
+ # bench.js: what the page shows, filter applied, as one .txt to read offline.
5442
+ "<button class='btn small' type='button' id='logs-export' title='Save the logs on this page, as"
5443
+ " filtered, to a text file'>Export as text</button></div></div>"
5623
5444
  + section("Gateway calls", "<table><thead><tr>" + th("When") + th("Collection") + th("Shape")
5624
5445
  + th("Seats") + f"</tr></thead><tbody>{''.join(gw)}</tbody></table>", len(gw), True)
5625
5446
  + section("Changes", "<table><thead><tr>" + th("When") + th("By") + th("Model") + th("What")
@@ -5938,8 +5759,7 @@ class Handler(gateway.Handler):
5938
5759
  if self.path == "/api/needs": # after an action there: that page, not the whole one
5939
5760
  with locked.snapshot():
5940
5761
  groups, auto = open_actions()
5941
- self._json(200, {"html": actions_page(groups, auto), "line": actions_line(groups),
5942
- "count": open_count(groups)})
5762
+ self._json(200, {"html": actions_page(groups, auto), "count": open_count(groups)})
5943
5763
  return
5944
5764
  if path == "/api/pane": # a tab's page, when first opened (bench.js load)
5945
5765
  key = q.get("tab", [""])[0]
@@ -6058,7 +5878,7 @@ class Handler(gateway.Handler):
6058
5878
  theme = q.get("theme", [None])[0] # a preview; else the saved one
6059
5879
  shown = re.search(r"(?:^|;)\s*bench_tab=(\w+)", self.headers.get("Cookie") or "") # the tab last shown
6060
5880
  try:
6061
- page = render(theme, shown.group(1) if shown else "glance")
5881
+ page = render(theme, shown.group(1) if shown else "suite")
6062
5882
  except RuntimeError as exc: # endpoints.json unreadable: say which and why
6063
5883
  page = f"<!doctype html><meta charset='utf-8'><title>Benchwarmers</title><p>{esc(exc)}</p>"
6064
5884
  self._page(page) # gzipped beyond this machine (_length)
package/ui/bench.css CHANGED
@@ -117,8 +117,6 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
117
117
 
118
118
  /* Action needed: its own page, a panel per connection as on Models, each a
119
119
  table (name, state, the provider's words, actions). */
120
- .a-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin: 1.4rem 1.6rem 0;
121
- padding: .45rem .75rem; background: var(--err-ground); border-radius: var(--radius); }
122
120
  .a-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; }
123
121
  .a-head .why { font-size: .875rem; }
124
122
  .a-head .why:empty { display: none; }
@@ -359,8 +357,7 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
359
357
  :is(dialog, .form-panel) .members .m-row { border-bottom: 1px solid var(--line); }
360
358
  :is(dialog, .form-panel) .members .m-row:nth-child(odd) { background: color-mix(in oklab, var(--band) 45%, transparent); }
361
359
  :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; }
360
+ :is(dialog, .form-panel) .members .m-name a.model { font-size: .875rem; }
364
361
  :is(dialog, .form-panel) .members .m-ctx { font-family: var(--font-mono); font-size: .8125rem; color: var(--muted); text-align: right; }
365
362
  :is(dialog, .form-panel) .members .m-can { display: flex; flex-wrap: wrap; gap: .2rem; }
366
363
  :is(dialog, .form-panel) .members .m-can .chip { font-size: .6875rem; padding: 0 .4rem; }
@@ -541,6 +538,14 @@ h3.chart-head { font-size: 1rem; margin: 1rem 0 .2rem; }
541
538
  .chart-label { font-size: .875rem; font-weight: 600; margin: .9rem 0 .1rem; }
542
539
  /* Models: each connection a fold, its heading the summary */
543
540
  .model-card > summary h3.chart-head { display: inline; margin: 0; }
541
+ /* Expand all / Collapse all: a text toggle with a chevron that turns, like a fold's own marker */
542
+ button.fold-all { display: inline-flex; align-items: center; gap: .4rem; margin-left: auto; border: 0; padding: .2rem 0;
543
+ background: none; color: var(--link); font: inherit; font-size: .9375rem; cursor: pointer; }
544
+ button.fold-all:hover, button.fold-all:focus-visible { text-decoration: underline; text-underline-offset: .18em; }
545
+ .fold-chev { width: .5rem; height: .5rem; border: solid currentColor; border-width: 0 2px 2px 0;
546
+ transform: rotate(-45deg); transition: transform .15s; }
547
+ button.fold-all[aria-expanded="true"] .fold-chev { transform: rotate(45deg); margin-top: -.3rem; }
548
+ @media (prefers-reduced-motion: reduce) { .fold-chev { transition: none; } }
544
549
  /* the Collections tab: the dialog's form, kept to a readable width */
545
550
  .panel.form-panel > :not(#c-list, table) { max-width: 46rem; }
546
551
  /* a list of things you open (collections), with its New button above */
@@ -595,7 +600,6 @@ dialog .pick { display: flex; gap: 1.5rem; }
595
600
  .toolbar input[type=search] { min-width: 0; width: 100%; }
596
601
  button.c-row { grid-template-columns: minmax(0, 1fr); } /* the seats get the width; the whole row opens */
597
602
  .c-row .open-hint { display: none; }
598
- .a-line { margin: 1rem 1rem 0; }
599
603
  }
600
604
  /* A narrow page (a phone, or a window with the sidebar beside it): name and
601
605
  state on one line, the words and actions under them. No gap between name
package/ui/bench.js CHANGED
@@ -213,7 +213,7 @@
213
213
  var rendered = document.querySelector('.tab:not([data-lazy])');
214
214
  Promise.resolve().then(function () { if (rendered) setUp(rendered.id.slice(4)); });
215
215
  var wanted = store('bench.tab'); // else the one rendered: storage blocked or cleared, the cookie kept
216
- show(wanted && $('tab-' + wanted) ? wanted : rendered ? rendered.id.slice(4) : 'glance');
216
+ show(wanted && $('tab-' + wanted) ? wanted : rendered ? rendered.id.slice(4) : 'suite');
217
217
 
218
218
  // ---- sortable tables -------------------------------------------------
219
219
  function key(cell) {
@@ -276,7 +276,75 @@
276
276
  document.addEventListener('input', function (ev) { if (ev.target.matches('input[data-filter]')) applyFilter(scopeOf(ev.target)); });
277
277
  document.addEventListener('change', function (ev) { if (ev.target.matches('select[data-facet]')) applyFilter(scopeOf(ev.target)); });
278
278
 
279
- // ---- a link to another page (At a glance's line to Action needed) ----
279
+ // ---- Expand all / Collapse all: every fold of the page's groups (Models' connections).
280
+ // One labelled toggle, as accordions have it: Collapse all while every one is open,
281
+ // else Expand all. Kept right after any fold opens or shuts, by hand, by it or by a live swap.
282
+ function foldButtons(tab) {
283
+ var folds = tab.querySelectorAll('details.model-card'), open = 0;
284
+ folds.forEach(function (d) { if (d.open) open++; });
285
+ var all = open === folds.length;
286
+ tab.querySelectorAll('[data-fold-all]').forEach(function (b) {
287
+ b.dataset.foldAll = all ? 'close' : 'open';
288
+ b.setAttribute('aria-expanded', String(all));
289
+ b.lastChild.textContent = all ? 'Collapse all' : 'Expand all';
290
+ });
291
+ }
292
+ document.addEventListener('click', function (e) {
293
+ var b = e.target.closest('[data-fold-all]');
294
+ if (!b) return;
295
+ var tab = b.closest('.tab');
296
+ tab.querySelectorAll('details.model-card').forEach(function (d) { d.open = b.dataset.foldAll === 'open'; });
297
+ foldButtons(tab);
298
+ });
299
+ document.addEventListener('toggle', function (e) { // toggle does not bubble: caught on its way down
300
+ if (e.target.matches && e.target.matches('details.model-card')) foldButtons(e.target.closest('.tab'));
301
+ }, true);
302
+
303
+ // ---- Logs: Export as text (every log in one .txt, to read) and CSV (one log, its
304
+ // own columns, for a spreadsheet). Both as the page shows them, the filter applied.
305
+ function stampNow() { // local time, as the page shows
306
+ var now = new Date(), two = function (n) { return String(n).padStart(2, '0'); };
307
+ return now.getFullYear() + '-' + two(now.getMonth() + 1) + '-' + two(now.getDate()) + '-' + two(now.getHours()) + two(now.getMinutes());
308
+ }
309
+ // The file itself: Share's saveFile(blob, name) and csv(rows) (RFC 4180, with a BOM).
310
+ document.addEventListener('click', function (e) {
311
+ var b = e.target.closest('[data-csv]');
312
+ if (!b) return;
313
+ e.preventDefault(); // in the summary: no fold
314
+ var d = b.closest('details'), rows = [];
315
+ var cells = function (tr) {
316
+ return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); });
317
+ };
318
+ var head = d.querySelector('thead tr');
319
+ if (head) {
320
+ rows.push(cells(head));
321
+ d.querySelectorAll('tbody tr').forEach(function (r) { if (!r.hidden) rows.push(cells(r)); });
322
+ } else {
323
+ rows.push(['Line']);
324
+ d.querySelectorAll('.log-line').forEach(function (r) { if (!r.hidden) rows.push([r.textContent.trim()]); });
325
+ }
326
+ var slug = d.dataset.log.toLowerCase().replace(/[^a-z0-9]+/g, '-');
327
+ saveFile(new Blob([csv(rows)], {type: 'text/csv;charset=utf-8'}), 'benchwarmers-' + slug + '-' + stampNow() + '.csv');
328
+ });
329
+ on('click', 'logs-export', function () {
330
+ var tab = $('tab-logs'), out = ['Benchwarmers logs, exported ' + new Date().toString()];
331
+ var q = (tab.querySelector('input[data-filter]') || {}).value;
332
+ if (q) out.push('Filtered by: ' + q);
333
+ tab.querySelectorAll('details.panel').forEach(function (d) {
334
+ out.push('', '== ' + d.dataset.log + ' ==');
335
+ var cells = function (tr) {
336
+ return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); }).join('\t');
337
+ };
338
+ var head = d.querySelector('thead tr');
339
+ if (head) out.push(cells(head));
340
+ d.querySelectorAll('tbody tr, .log-line').forEach(function (r) {
341
+ if (!r.hidden) out.push(r.matches('tr') ? cells(r) : r.textContent.trim());
342
+ });
343
+ });
344
+ saveFile(new Blob([out.join('\n') + '\n'], {type: 'text/plain;charset=utf-8'}), 'benchwarmers-logs-' + stampNow() + '.txt');
345
+ });
346
+
347
+ // ---- a link to another page ----
280
348
  document.addEventListener('click', function (e) {
281
349
  var b = e.target.closest('[data-go]');
282
350
  if (b) { show(b.dataset.go); scrollTo(0, 0); }
@@ -767,8 +835,8 @@
767
835
  });
768
836
 
769
837
  // ---- after an action: the part it changed, not the whole page ----------
770
- // Action needed's page, its count in the sidebar and At a glance's line
771
- // about it, from the server (GET /api/needs, a few ms): a page reload
838
+ // Action needed's page and its count in the sidebar, from the server
839
+ // (GET /api/needs, a few ms): a page reload
772
840
  // renders every tab, seconds.
773
841
  var needsAsked = 0;
774
842
  function refreshNeeds(live) {
@@ -783,10 +851,6 @@
783
851
  count.textContent = n.count;
784
852
  tab.setAttribute('aria-label', tab.firstChild.textContent + ', ' + n.count + ' open');
785
853
  }
786
- var line = document.querySelector('#tab-glance > .a-line');
787
- if (line) line.remove();
788
- // Not into a page not fetched yet: it says Loading… while empty, and comes with its line.
789
- if (n.line && $('tab-glance').firstChild) $('tab-glance').insertAdjacentHTML('afterbegin', n.line);
790
854
  }).catch(function () { location.reload(); }); // could not ask: the old way
791
855
  }
792
856
  // A retired model's rows grey out and hide behind Show retired, as the
@@ -2125,7 +2189,10 @@
2125
2189
  var first = fact(g.name), caps = first.caps || {};
2126
2190
  var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
2127
2191
  var name = el('div', {class: 'm-name'});
2128
- name.appendChild(el('a', {href: '#', class: 'model mono', 'data-alias': g.name, title: 'Open ' + g.name}, g.name));
2192
+ // The same link as every model name elsewhere (a.model > .alias: the highlight on hover).
2193
+ var link = el('a', {href: '#', class: 'model', 'data-alias': g.name, title: 'Open ' + g.name});
2194
+ link.appendChild(el('span', {class: 'alias'}, g.name));
2195
+ name.appendChild(link);
2129
2196
  line.appendChild(name);
2130
2197
  line.appendChild(el('span', {class: 'm-ctx'}, first.context ? tokens(first.context) : '—'));
2131
2198
  var can = el('span', {class: 'm-can'});