@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.0

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/serve.py CHANGED
@@ -12,7 +12,8 @@ It writes three kinds of thing, each behind a popup and each logged to
12
12
  `config/changes.jsonl`. A model's ROLE or ALIAS (click its name): rules from
13
13
  the bench itself — a role the config defines; an alias in the standing form,
14
14
  unused, never a retired name — written atomically, the rename recorded in
15
- `config/alias-history.json`, the `llm` registry re-synced. A model's REMOVAL
15
+ `config/alias-history.json`, the `llm` registry re-synced while it is the
16
+ client. A model's REMOVAL
16
17
  (same popup, confirmed, reason optional): deregistered, taken
17
18
  out of every collection, and recorded in `config/retired.json`; no evidence
18
19
  is deleted. And ONBOARDING
@@ -35,6 +36,7 @@ from __future__ import annotations
35
36
 
36
37
  import csv
37
38
  import datetime
39
+ import functools
38
40
  import html
39
41
  import json
40
42
  import os
@@ -125,11 +127,14 @@ def font_file(name: str) -> pathlib.Path | None:
125
127
  # Images a theme may draw (Hardwood's centre court): named here, served from
126
128
  # ui/ with a fixed type, so the route cannot be walked to other files.
127
129
  ASSETS = {"mascot.png": "image/png"}
130
+ # The logo, header and byline in one, for a shared report's image (bench.js
131
+ # "share"): the repository's own, at /ui/<name>; light and dark grounds.
132
+ LOGOS = {"logo.svg": ROOT / ".github" / "logo.svg", "logo-dark.svg": ROOT / ".github" / "logo-dark.svg"}
128
133
 
129
134
 
130
135
  def read(path: pathlib.Path) -> str:
131
136
  try:
132
- return path.read_text(encoding="utf-8")
137
+ return locked.read_text(path) # inside a render, from its snapshot
133
138
  except OSError:
134
139
  return ""
135
140
 
@@ -140,10 +145,11 @@ def load_json(path: pathlib.Path) -> dict:
140
145
 
141
146
  def data_stamp() -> float:
142
147
  """Newest change to anything the page renders from. The page polls this
143
- and offers a reload when it moves, rather than reloading under you."""
148
+ and, when it moves, fetches the page shown again (bench.js, "live")."""
144
149
  paths = [*DATA.glob("config/*.json*"), *DATA.glob("config/*.yaml"),
145
- *DATA.glob("metrics/*.json*"), DATA / "perf" / "history.csv",
146
- *DATA.glob("runs/*.md")]
150
+ *DATA.glob("metrics/*.json*"), *DATA.glob("metrics/usage/*.jsonl"),
151
+ DATA / "perf" / "history.csv", *DATA.glob("runs/*.md"),
152
+ *DATA.glob("runs/gateway/*.jsonl"), DATA / "runs" / "scheduler.log"]
147
153
  paths = [p for p in paths if p.name != UI_CONFIG.name] # a theme switch is already on the page
148
154
  return max((p.stat().st_mtime for p in paths if p.exists()), default=0.0)
149
155
 
@@ -172,26 +178,92 @@ def suite_scores() -> dict:
172
178
  had that role stay in the notes, uncounted. Real work, perf and reviews
173
179
  still count it. Nor the vision tasks (vision-*): the Vision panel reads
174
180
  those (vision_rows).
181
+
182
+ Read once a render (four panes ask), each caller given its own copy.
175
183
  """
184
+ return {k: dict(v) for k, v in locked.once(("suite_scores",), _suite_scores).items()}
185
+
186
+
187
+ def _suite_scores() -> dict:
176
188
  from run import images_only
177
- row = re.compile(r"^\|\s*([\w-]+)\s*\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|"
178
- r"\s*([^|]*?)\s*\|\s*([\d.]+)\s*\|")
179
189
  scores: dict[tuple[str, str], dict] = {}
180
190
  skip = images_only()
181
- for path in sorted(DATA.glob("runs/suite-*.md")):
182
- for line in read(path).splitlines():
183
- if (m := row.match(line.strip())):
184
- task, model, found, invented, seconds = m.groups()
185
- model = current_alias(model)
186
- if model in skip or task.startswith("vision-"):
187
- continue
188
- scores[(task, model)] = {
189
- "found": found, "invented": invented.strip(" —-"),
190
- "seconds": float(seconds)}
191
+ for _day, _run, task, model, found, invented, seconds in note_rows():
192
+ model = current_alias(model)
193
+ if model in skip or task.startswith("vision-"):
194
+ continue
195
+ scores[(task, model)] = {
196
+ "found": found, "invented": invented.strip(" —-"),
197
+ "seconds": float(seconds)}
191
198
  return scores
192
199
 
193
200
 
201
+ # A row of a run note's table (suite/run._write_report). The seconds may carry
202
+ # the call's attempts, "3.2 (x3)": such rows were skipped before 2026-11-08.
203
+ _NOTE_ROW = re.compile(r"^\|\s*([\w-]+)\s*\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|"
204
+ r"\s*([^|]*?)\s*\|\s*([\d.]+)(?:\s*\(x\d+\))?\s*\|")
205
+ _notes: dict = {} # path -> ((mtime_ns, size), rows)
206
+ _notes_all: tuple = (None, []) # (the listing's stats, every row)
207
+
208
+
209
+ def note_rows() -> list[tuple]:
210
+ """Every table row of the run notes (runs/suite-*.md), in order, as
211
+ written: (day, run, task, alias, found, invented, seconds). `run` counts
212
+ the note's "## Suite run" sections (one per run, in start order); `day` is
213
+ "" for a note not named by its date. Shared: read it, never change it."""
214
+ return _note_table()[1]
215
+
216
+
217
+ def _note_table() -> tuple:
218
+ """(the notes' stats, note_rows()). A note is parsed again only when its
219
+ listing's stat changes: only today's does."""
220
+ global _notes_all
221
+ listing = []
222
+ try:
223
+ for e in os.scandir(DATA / "runs"):
224
+ if e.name.startswith("suite-") and e.name.endswith(".md"):
225
+ try:
226
+ listing.append((e.path, e.name, e.stat()))
227
+ except OSError: # gone since the listing: the rest still count
228
+ pass
229
+ except OSError:
230
+ pass
231
+ listing.sort(key=lambda e: e[1])
232
+ key = tuple((path, st.st_mtime_ns, st.st_size) for path, _name, st in listing)
233
+ seen = _notes_all
234
+ if seen[0] == key:
235
+ return seen
236
+ out: list[tuple] = []
237
+ for path, name, st in listing:
238
+ hit = _notes.get(path)
239
+ if hit is None or hit[0] != (st.st_mtime_ns, st.st_size):
240
+ day = name[6:-3] if re.fullmatch(r"\d{4}-\d\d-\d\d", name[6:-3]) else ""
241
+ # From disk, not the render's copy (read): a note appended since this
242
+ # render first read it would be kept as it was, under its new stat.
243
+ try:
244
+ text = pathlib.Path(path).read_text(encoding="utf-8")
245
+ except OSError: # this time only: kept under no key, read again next time
246
+ key = None
247
+ continue
248
+ rows, run = [], 0
249
+ for line in text.splitlines():
250
+ if line.startswith("## Suite run"):
251
+ run += 1
252
+ elif (m := _NOTE_ROW.match(line.strip())):
253
+ rows.append((day, run, *m.groups()))
254
+ hit = _notes[path] = ((st.st_mtime_ns, st.st_size), rows)
255
+ out += hit[1]
256
+ _notes_all = (key, out)
257
+ return _notes_all
258
+
259
+
194
260
  def perf_rows() -> list[dict]:
261
+ """perf/history.csv, read once a render (Benchmarks asks three times),
262
+ each caller given its own copies."""
263
+ return [dict(r) for r in locked.once(("perf_rows",), _perf_rows)]
264
+
265
+
266
+ def _perf_rows() -> list[dict]:
195
267
  path = DATA / "perf" / "history.csv"
196
268
  if not path.exists():
197
269
  return []
@@ -491,17 +563,7 @@ def change_model(alias: str, new_alias: str | None, new_role: str | None,
491
563
  if is_decision:
492
564
  return {"ok": True, "errors": [], "changed": changed, "sync": ""}
493
565
  # 4. push the registry to where the CLI reads it
494
- try:
495
- result = subprocess.run(
496
- [sys.executable, str(ROOT / "scripts" / "sync_config.py")],
497
- cwd=str(ROOT), capture_output=True, text=True, timeout=120,
498
- encoding="utf-8", errors="replace")
499
- sync_report = ("synced to the llm CLI" if result.returncode == 0
500
- else f"sync_config.py exited {result.returncode}:"
501
- f" {(result.stderr or result.stdout).strip()[:300]}"
502
- " — run it by hand")
503
- except (OSError, subprocess.SubprocessError) as exc:
504
- sync_report = f"could not run scripts/sync_config.py ({exc}); run it by hand"
566
+ sync_report = _sync_cli()
505
567
 
506
568
  return {"ok": True, "errors": [], "changed": changed, "sync": sync_report}
507
569
 
@@ -574,7 +636,19 @@ def _return_to_collections(alias: str, was: dict, stamp: str, who: str) -> list[
574
636
  return back
575
637
 
576
638
 
639
+ # The llm CLI keeps its own copy of the registry and the options, which only
640
+ # it reads: the built-in client reads the data folder's. So the copy is kept
641
+ # in step only while the llm CLI is the client (it cost 5-6 s a save), and
642
+ # save_client syncs it whole when it becomes the client.
643
+ # ponytail: BENCH_CLIENT=llm set at a restart is not a switch anyone sees here;
644
+ # run scripts/sync_config.py once then, or sync at startup if that becomes usual.
645
+ NOT_SYNCED = "not synced: the built-in client is in use; synced when you switch to the llm CLI"
646
+
647
+
577
648
  def _sync_cli() -> str:
649
+ import client as model_client
650
+ if model_client.chosen()[0] != "llm":
651
+ return NOT_SYNCED
578
652
  try:
579
653
  result = subprocess.run(
580
654
  [sys.executable, str(ROOT / "scripts" / "sync_config.py")],
@@ -593,7 +667,8 @@ def remove_model(alias: str, reason: str = "", who: str = "ui") -> dict:
593
667
 
594
668
  The daily check (`scripts/check_deprecations.py`, via `auto_retire.py`)
595
669
  calls this too, with `who="auto"`, when a model is clearly gone; this is
596
- also the button for a person who knows sooner. It deregisters the alias from the llm registry (and re-syncs),
670
+ also the button for a person who knows sooner. It deregisters the alias from the llm registry (and
671
+ re-syncs the llm CLI while it is the client),
597
672
  drops it from the role and direct-endpoint configs and from every
598
673
  collection (members and merger), records it in
599
674
  `config/retired.json` — so the alias is never reused, and onboarding the
@@ -1244,6 +1319,18 @@ def connect_list_suggest(provider: str) -> str:
1244
1319
  return provider if local and _signed_in_as(provider, provider) else free_name(provider)
1245
1320
 
1246
1321
 
1322
+ def connect_local() -> dict:
1323
+ """Model servers running on this machine, at their usual ports or wherever
1324
+ their processes listen, that are not endpoints yet (connect.detect), each
1325
+ with the name it would be added under. Asked when the Endpoints page is
1326
+ shown, never in its render: a look takes a second or two. Never this
1327
+ install's ports, nor another one's defaults."""
1328
+ found = connect.detect(skip={PORT, gateway.PORT, 8122, 8123})
1329
+ for f in found:
1330
+ f["name"] = connect_list_suggest(f["provider"]) if f["provider"] else free_name(f["id"])
1331
+ return {"ok": True, "errors": [], "found": found}
1332
+
1333
+
1247
1334
  def connect_app(provider: str, client_id: str) -> dict:
1248
1335
  """Use your own app: this install's client id for a provider that needs a
1249
1336
  registration, instead of the one Benchwarmers ships. Empty goes back to it."""
@@ -1385,9 +1472,13 @@ def save_model_facts(alias: str, facts: dict, who: str = "ui") -> dict:
1385
1472
 
1386
1473
  def _llm_option(alias: str, name: str, value) -> str:
1387
1474
  """The llm CLI keeps its own copy of a model's options (sync_config.py
1388
- sets them from the file), so a change here is made there too; None clears
1389
- it. The built-in client reads the file itself."""
1475
+ sets them from the file), so a change here is made there too while it is
1476
+ the client (NOT_SYNCED); None clears it. The built-in client reads the
1477
+ file itself."""
1390
1478
  import shutil
1479
+ import client as model_client
1480
+ if model_client.chosen()[0] != "llm":
1481
+ return NOT_SYNCED
1391
1482
  if not shutil.which("llm"): # found the way every llm call here finds it
1392
1483
  return "no llm CLI on this machine's PATH, so only the built-in client uses it"
1393
1484
  # `--`: whatever follows is an argument, never a flag, whatever the alias.
@@ -1500,7 +1591,7 @@ def collections_view() -> dict:
1500
1591
  "price": price_of(a, served[a], f), "role": (roles.get("models") or {}).get(a, "general"),
1501
1592
  "family": route.family_of(a), "provider": provider_label(tag), "connection": tag}
1502
1593
  return {"ok": True, "errors": [], "collections": rows, "settings": s,
1503
- "shapes": shapes, "models": _chat_aliases(), "served": served, "tag_suggestions": suggestions,
1594
+ "pick": bench_collections.pick_settings(), "confident": decide.CONFIDENT, "shapes": shapes, "models": _chat_aliases(), "served": served, "tag_suggestions": suggestions,
1504
1595
  "facts": facts, "shape_roles": {k: v for k, v in roles["shapes"].items() if k in shapes},
1505
1596
  "long_context": LONG_CONTEXT}
1506
1597
 
@@ -1583,12 +1674,25 @@ def delete_collection(name: str, who: str = "ui") -> dict:
1583
1674
  return {"ok": True, "errors": [], "changed": ["config/collections.json"]}
1584
1675
 
1585
1676
 
1586
- def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
1677
+ KEEP = object() # a setting the request did not send: left as it is
1678
+
1679
+
1680
+ def save_gateway_settings(max_calls, max_explorers, who: str = "ui", pick_collection=KEEP,
1681
+ pick_confidence=KEEP) -> dict:
1682
+ """The gateway's limits, and the collection pick for a request that names
1683
+ none (bench_collections.pick_settings): `pick_collection` true or false,
1684
+ `pick_confidence` above 0 and at most 1, or None for the default."""
1587
1685
  errors = []
1588
1686
  if not isinstance(max_calls, int) or isinstance(max_calls, bool) or not 1 <= max_calls <= 10:
1589
1687
  errors.append("max calls must be a whole number from 1 to 10")
1590
1688
  if not isinstance(max_explorers, int) or isinstance(max_explorers, bool) or not 0 <= max_explorers <= 2:
1591
1689
  errors.append("max explorers must be 0, 1 or 2 (route.py seats at most two)")
1690
+ if pick_collection is not KEEP and not isinstance(pick_collection, bool):
1691
+ errors.append("choosing a collection must be on or off")
1692
+ if pick_confidence is not KEEP and pick_confidence is not None and not (
1693
+ isinstance(pick_confidence, (int, float)) and not isinstance(pick_confidence, bool)
1694
+ and 0 < pick_confidence <= 1):
1695
+ errors.append(f"the confidence must be above 0 and at most 1, or empty for {decide.CONFIDENT}")
1592
1696
  too_big = [n for n, c in bench_collections.load().items()
1593
1697
  if isinstance(max_calls, int) and len(c.get("members", [])) > max_calls
1594
1698
  and "max_calls" not in bench_collections.own_limits(c)]
@@ -1599,12 +1703,18 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
1599
1703
  if errors:
1600
1704
  return {"ok": False, "errors": errors}
1601
1705
  raw = bench_collections._read(bench_collections.SETTINGS_FILE)
1602
- old = bench_collections.settings()
1706
+ old = {**bench_collections.settings(), **bench_collections.pick_settings()}
1603
1707
  raw.pop("default_collection", None)
1604
1708
  raw.update({"max_calls": max_calls, "max_explorers": max_explorers})
1709
+ if pick_collection is not KEEP:
1710
+ raw["pick_collection"] = pick_collection
1711
+ if pick_confidence is None:
1712
+ raw.pop("pick_confidence", None) # empty: the default, decide.CONFIDENT
1713
+ elif pick_confidence is not KEEP:
1714
+ raw["pick_confidence"] = pick_confidence
1605
1715
  _write_atomic(bench_collections.SETTINGS_FILE, _dump(raw))
1606
1716
  _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
1607
- who, "", "gateway settings", old, bench_collections.settings())
1717
+ who, "", "gateway settings", old, {**bench_collections.settings(), **bench_collections.pick_settings()})
1608
1718
  return {"ok": True, "errors": [], "changed": ["config/gateway.json"]}
1609
1719
 
1610
1720
 
@@ -1899,7 +2009,11 @@ def card(title: str, subtitle: str, body: str, key: str, facets: str = "") -> st
1899
2009
 
1900
2010
 
1901
2011
  def th(label: str, num: bool = False) -> str:
1902
- return f"<th data-sortable{' class=num' if num else ''}>{label}</th>"
2012
+ return f"<th data-sortable tabindex='0'{' class=num' if num else ''}>{label}</th>"
2013
+
2014
+
2015
+ def ext_link(url: str, text: str) -> str:
2016
+ return f"<a href='{esc(url)}' target='_blank' rel='noopener'>{esc(text)}</a>"
1903
2017
 
1904
2018
 
1905
2019
  STATUS = {
@@ -1919,7 +2033,12 @@ def model_status() -> dict[str, tuple[str, str, str]]:
1919
2033
  """alias -> (label, chip class, why), for every registered or retired model.
1920
2034
 
1921
2035
  One vocabulary for the whole page, so the roster, the problems and the
1922
- editor never describe the same model two ways."""
2036
+ editor never describe the same model two ways. Worked out once a render
2037
+ (four panes ask), each caller given its own copy (the values are tuples)."""
2038
+ return dict(locked.once(("model_status",), _model_status))
2039
+
2040
+
2041
+ def _model_status() -> dict[str, tuple[str, str, str]]:
1923
2042
  alerts = load_json(DATA / "metrics" / "alerts.json")
1924
2043
  pending = {a.get("alias"): a for k, a in alerts.items()
1925
2044
  if k.startswith("model:pending:")}
@@ -2138,7 +2257,7 @@ def actions_page(groups: dict, auto: list) -> str:
2138
2257
  if not url:
2139
2258
  return ""
2140
2259
  shown = re.sub(r"^https?://", "", url).split("?")[0]
2141
- return f" <a href='{esc(url)}' target='_blank' rel='noopener'>{esc(shown)} ↗</a>"
2260
+ return " " + ext_link(url, shown + " ↗")
2142
2261
 
2143
2262
  def why(text: str, url: str, title: str) -> str:
2144
2263
  return (f"<td class='why' title='{esc(title)}'><div class='said-line'><span class='said'>{esc(text)}</span>"
@@ -2281,7 +2400,8 @@ def roster() -> str:
2281
2400
  directory = model_directory()
2282
2401
  severity = {"err": 0, "warn": 1, "": 2, "ok": 3}
2283
2402
 
2284
- body = ["<table><thead><tr>", th("Model"), th("Status"), th("Role"),
2403
+ up = uptime_rows()["models"]
2404
+ body = ["<table><thead><tr>", th("Model"), th("Status"), th("Uptime 7d", True), th("Role"),
2285
2405
  th("Context", True), th("Can"),
2286
2406
  th("Offloads", True), th("Helped"), th("Suite", True),
2287
2407
  th("Reply time", True), th("Last answer"), "</tr></thead><tbody>"]
@@ -2326,7 +2446,7 @@ def roster() -> str:
2326
2446
  f"onboarding stopped at the {stuck[alias].get('step', 'probe')}:"
2327
2447
  f" {stuck[alias]['label']}. Open the model for the error.")
2328
2448
  if alias in stuck else "")
2329
- + f"</td><td>{esc(role)}</td>{facts_cells(alias, f)}"
2449
+ + "</td>" + uptime_cell(up.get(alias)) + f"<td>{esc(role)}</td>{facts_cells(alias, f)}"
2330
2450
  f"<td class='num'>{w.get('offloads', 0)}</td>"
2331
2451
  f"<td data-sort='{rate:.3f}'>{helped}</td>{suite_cell}"
2332
2452
  + (f"<td class='num' data-sort='{p50}'>{p50:.1f}s</td>" if p50 is not None
@@ -2346,7 +2466,7 @@ def roster() -> str:
2346
2466
  " availability probe yet'>—</span>"
2347
2467
  + (" " + chip("new", "new", "onboarded this week") if d.get("onboarded", "") >=
2348
2468
  (datetime.date.today() - datetime.timedelta(days=7)).isoformat() else "")
2349
- + f"</td><td>decision · {esc(', '.join(d['question_types']))}</td>"
2469
+ + "</td>" + uptime_cell(up.get(alias)) + f"<td>decision · {esc(', '.join(d['question_types']))}</td>"
2350
2470
  "<td class='num muted' data-sort='-1'>—</td><td class='muted'>—</td>"
2351
2471
  f"<td class='num'>{w.get('offloads', 0)}</td>"
2352
2472
  "<td data-sort='-1'><span class='muted'>not judged</span></td>"
@@ -2359,6 +2479,9 @@ def roster() -> str:
2359
2479
  never retire a model inside 7 days; <em>retiring in Nd</em> is the
2360
2480
  countdown before the bench retires it on its own. Hover a chip for the
2361
2481
  provider's own words.</dd>
2482
+ <dt>Uptime 7d</dt><dd>Of its calls in the last 7 days that say anything about the provider,
2483
+ the share answered: busy, unreachable and timed out count against it; our own quota,
2484
+ pacing and setup problems are left out. Benchmarks has it a day at a time.</dd>
2362
2485
  <dt>Offloads, Helped</dt><dd>Real work handed over, and how much of what
2363
2486
  was judged actually helped. Benchmarks never count here.</dd>
2364
2487
  <dt>Suite</dt><dd>Verified findings located across the internal tasks:
@@ -2388,12 +2511,25 @@ def on_bench() -> set[str]:
2388
2511
  return set(registered().values())
2389
2512
 
2390
2513
 
2514
+ # Every report with a chart shares the same way (bench.js "share"): its charts as
2515
+ # an image, its table as Markdown or CSV, or a post with the image on the clipboard.
2516
+ SHARE = ("<details class='share'><summary>Share</summary><div class='share-menu'>"
2517
+ + "".join(f"<button type='button' class='linkish' data-share='{k}'>{label}</button>" for k, label in (
2518
+ ("copy", "Copy image"), ("png", "Download image"), ("md", "Copy table as Markdown"),
2519
+ ("csv", "Download table as CSV"), ("native", "Share…")))
2520
+ + "<span class='share-to'>Post on " + ", ".join(
2521
+ f"<button type='button' class='linkish' data-share='{k}'>{label}</button>" for k, label in (
2522
+ ("x", "X"), ("bsky", "Bluesky"), ("linkedin", "LinkedIn"), ("reddit", "Reddit")))
2523
+ + "</span></div></details>")
2524
+
2525
+
2391
2526
  def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = "",
2392
- retired: bool = True) -> str:
2527
+ retired: bool = True, after: str = "") -> str:
2393
2528
  """A Benchmarks panel: the chart first (drawn by bench.js with Observable
2394
- Plot), the table folded under it, the key under that."""
2395
- return (f"<div class='panel'><h2>{title}</h2><p class='sub'>{sub}</p>{extra}"
2396
- f"<div class='chart' id='{chart_id}'></div>"
2529
+ Plot), the table folded under it, the key under that. `extra` goes above
2530
+ the chart, `after` right under it."""
2531
+ return (f"<div class='panel'>{SHARE}<h2>{title}</h2><p class='sub'>{sub}</p>{extra}"
2532
+ f"<div class='chart' id='{chart_id}'></div>{after}"
2397
2533
  "<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
2398
2534
  " data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
2399
2535
  + ("<label><input type='checkbox' data-retired> Show retired</label>" if retired else "") + "</div>"
@@ -2409,24 +2545,52 @@ def _family(task: str) -> str:
2409
2545
  return "hand-built"
2410
2546
 
2411
2547
 
2412
- _wrong: tuple = (0.0, {})
2548
+ _wrong: tuple = (None, {}) # (key, wrong.scores())
2549
+ _wrong_table: tuple = (None, {}) # (key, wrong.table())
2550
+
2551
+
2552
+ def _tasks_key(tasks: pathlib.Path) -> tuple:
2553
+ """(path, mtime, size) of every file in each task's folder and its output
2554
+ folder: what scoring reads. The listings carry the stats on Windows."""
2555
+ key = []
2556
+ for task in (os.scandir(tasks) if tasks.is_dir() else ()):
2557
+ for folder in (task.path, os.path.join(task.path, "output")):
2558
+ try:
2559
+ key += [(e.path, e.stat().st_mtime_ns, e.stat().st_size) for e in os.scandir(folder) if e.is_file()]
2560
+ except OSError: # not a folder, or no output yet
2561
+ pass
2562
+ return tuple(key)
2413
2563
 
2414
2564
 
2415
2565
  def wrong_table() -> dict:
2416
- """wrong.table(), shared by the three cards of one render: it scores every
2417
- suite answer, and asking three times was a second of each reload."""
2418
- global _wrong
2566
+ """wrong.table(). The suite answers are scored again only when an answer,
2567
+ a task, a rename or a model's role changes (it reads them all: asked three
2568
+ times a render, then kept 5 seconds, it was 0.7 s of each reload); the
2569
+ reviews are counted again when the offloads change."""
2570
+ global _wrong, _wrong_table
2571
+ import aliases
2572
+ import offload
2419
2573
  import wrong
2420
2574
  from run import images_only
2421
- seen = _wrong
2422
- if time.monotonic() - seen[0] > 5:
2423
- table = wrong.table()
2575
+ vision = images_only()
2576
+ key = (locked.once(("tasks key",), lambda: _tasks_key(wrong.TASKS)), aliases.history(), vision)
2577
+ scored = _wrong
2578
+ if scored[0] != key:
2579
+ s = wrong.scores()
2424
2580
  # A vision or image model keeps its reviews (real work, judged) but not
2425
2581
  # its code-task answers: the suite is not what it is for (suite_scores).
2426
- for alias in images_only() & set(table):
2427
- r = table[alias]
2428
- r.update(answers=0, wrong=0, by={k: [0, 0] for k in r["by"]}, hunt=dict.fromkeys(r["hunt"], 0))
2429
- seen = _wrong = (time.monotonic(), table) # stamped once scored, not before
2582
+ for alias in vision & set(s):
2583
+ s[alias].update(answers=0, wrong=0, by={k: [0, 0] for k in s[alias]["by"]},
2584
+ hunt=dict.fromkeys(s[alias]["hunt"], 0))
2585
+ scored = _wrong = (key, s) # keyed before scoring: a change meanwhile scores again
2586
+ try:
2587
+ st = offload.LOG.stat()
2588
+ log = st.st_mtime_ns, st.st_size
2589
+ except OSError:
2590
+ log = None
2591
+ seen = _wrong_table
2592
+ if seen[0] != (key, log):
2593
+ seen = _wrong_table = ((key, log), wrong.table(scored[1]))
2430
2594
  return seen[1]
2431
2595
 
2432
2596
 
@@ -2454,7 +2618,9 @@ def charts_data() -> dict:
2454
2618
  return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
2455
2619
  "seeded, whole file", "real bugs"],
2456
2620
  "wrong": bars, "wrongTotals": totals, "kinds": list(wrong.KINDS), "hunt": hunt,
2457
- "work": work_rows(), "vision": vision_rows(), **perf_charts()}
2621
+ "work": work_rows(), "vision": vision_rows(), "uptime": uptime_data(), "trend": trend_data(),
2622
+ "decide": decision_data(),
2623
+ **perf_charts()}
2458
2624
 
2459
2625
 
2460
2626
  def work_rows() -> list[dict]:
@@ -2547,6 +2713,70 @@ def _vision_family(spec: dict) -> str:
2547
2713
  return next((name for prefix, name in VISION_FAMILIES if spec["id"].startswith(prefix)), "own")
2548
2714
 
2549
2715
 
2716
+ _gateway_seen: dict = {"mark": {}, "seats": [], "read": 0}
2717
+ _gateway_lock = threading.Lock()
2718
+
2719
+
2720
+ def _gateway_fold(calls: pathlib.Path) -> list[tuple]:
2721
+ """Every gateway seat, oldest first: (panel shape, panel at, alias, call,
2722
+ status, answered, seconds, error). Only the lines added since the last call
2723
+ are parsed: the log runs to 50 MB, and the gateway adds to it on every
2724
+ call. Vision and Uptime both read it, so it is parsed once. Hold
2725
+ _gateway_lock; `read` counts the times it started over."""
2726
+ fresh, lines = locked.appended(calls, _gateway_seen["mark"])
2727
+ if fresh:
2728
+ _gateway_seen["seats"] = []
2729
+ _gateway_seen["read"] += 1
2730
+ for raw in lines:
2731
+ try:
2732
+ panel = json.loads(raw)
2733
+ except ValueError:
2734
+ continue
2735
+ shape, at = panel.get("shape"), panel.get("at") or ""
2736
+ _gateway_seen["seats"] += [(shape, at, s.get("alias", ""), s.get("call"), s.get("status"),
2737
+ s.get("status") == "ok" and bool((s.get("answer") or "").strip()),
2738
+ s.get("seconds"), s.get("error") or "") for s in panel.get("seats", [])]
2739
+ return _gateway_seen["seats"]
2740
+
2741
+
2742
+ def _gateway_seats(calls: pathlib.Path) -> list[tuple]:
2743
+ with _gateway_lock:
2744
+ return list(_gateway_fold(calls))
2745
+
2746
+
2747
+ _seat_days: dict = {"read": None, "since": "", "n": 0, "counts": {}}
2748
+
2749
+
2750
+ def _seat_counts(since: str) -> dict:
2751
+ """(day, alias as logged, status, error) -> (seats, answered seconds, last
2752
+ at) for the gateway's seats from `since` on, the new seats folded in: a
2753
+ render then resolves a few thousand keys, not every seat."""
2754
+ with _gateway_lock:
2755
+ seats, s = _gateway_fold(DATA / "runs" / "gateway" / "calls.jsonl"), _seat_days
2756
+ if (s["read"], s["since"]) != (_gateway_seen["read"], since):
2757
+ s.update(read=_gateway_seen["read"], since=since, n=0, counts={})
2758
+ counts = s["counts"]
2759
+ for _shape, at, alias, _call, status, _answered, seconds, error in seats[s["n"]:]:
2760
+ if at[:10] < since:
2761
+ continue
2762
+ c = counts.get(k := (at[:10], alias, status or "error", error))
2763
+ if c is None:
2764
+ c = counts[k] = [0, [], ""]
2765
+ c[0] += 1
2766
+ if seconds and status == "ok":
2767
+ c[1].append(float(seconds))
2768
+ c[2] = max(c[2], at)
2769
+ s["n"] = len(seats)
2770
+ return {k: (n, list(secs), last) for k, (n, secs, last) in counts.items()}
2771
+
2772
+
2773
+ def _vision_seat_list(calls: pathlib.Path) -> list[tuple]:
2774
+ """Every seat of the gateway's vision panels, oldest first: (alias, call,
2775
+ answered, seconds)."""
2776
+ return [(alias, call, answered, seconds)
2777
+ for shape, _, alias, call, _, answered, seconds, _ in _gateway_seats(calls) if shape == "vision"]
2778
+
2779
+
2550
2780
  def _vision_work_rows(live: set[str]) -> dict:
2551
2781
  """alias -> {seat kind: count, claims right, claims wrong, seconds}; read
2552
2782
  again only when the gateway's log or the verdicts change (5 MB a render)."""
@@ -2561,32 +2791,25 @@ def _vision_work_rows(live: set[str]) -> dict:
2561
2791
  from offload import entries
2562
2792
  judged = {e["call"]: e for e in entries() if e.get("call") and e.get("useful") is not None}
2563
2793
  out: dict[str, dict] = {}
2564
- for raw in read(calls).splitlines():
2565
- try:
2566
- panel = json.loads(raw)
2567
- except ValueError:
2794
+ for alias, call, answered, seconds in _vision_seat_list(calls):
2795
+ alias = current_alias(alias)
2796
+ if alias not in live:
2568
2797
  continue
2569
- if panel.get("shape") != "vision":
2570
- continue
2571
- for s in panel.get("seats", []):
2572
- alias = current_alias(s.get("alias", ""))
2573
- if alias not in live:
2574
- continue
2575
- r = out.setdefault(alias, {**dict.fromkeys(VISION_SEATS, 0), "real": 0, "false": 0, "seconds": []})
2576
- v = judged.get(s.get("call"))
2577
- if s.get("status") != "ok" or not (s.get("answer") or "").strip():
2578
- kind = "no answer"
2579
- elif v is None:
2580
- kind = "not judged"
2581
- elif v.get("real") or v.get("false"):
2582
- kind = "checked, helped" if v["useful"] else "checked, did not help"
2583
- r["real"] += len(v.get("real") or [])
2584
- r["false"] += len(v.get("false") or [])
2585
- else:
2586
- kind = "useful, no claims" if v["useful"] else "no use, no claims"
2587
- r[kind] += 1
2588
- if kind != "no answer" and s.get("seconds"):
2589
- r["seconds"].append(float(s["seconds"]))
2798
+ r = out.setdefault(alias, {**dict.fromkeys(VISION_SEATS, 0), "real": 0, "false": 0, "seconds": []})
2799
+ v = judged.get(call)
2800
+ if not answered:
2801
+ kind = "no answer"
2802
+ elif v is None:
2803
+ kind = "not judged"
2804
+ elif v.get("real") or v.get("false"):
2805
+ kind = "checked, helped" if v["useful"] else "checked, did not help"
2806
+ r["real"] += len(v.get("real") or [])
2807
+ r["false"] += len(v.get("false") or [])
2808
+ else:
2809
+ kind = "useful, no claims" if v["useful"] else "no use, no claims"
2810
+ r[kind] += 1
2811
+ if kind != "no answer" and seconds:
2812
+ r["seconds"].append(float(seconds))
2590
2813
  for r in out.values():
2591
2814
  secs = sorted(r.pop("seconds"))
2592
2815
  r["seconds"] = secs[len(secs) // 2] if secs else None
@@ -2637,7 +2860,7 @@ def vision_card() -> str:
2637
2860
  held = (f"<p class='hint'>The vision tasks wait until {esc(v['held'])}: no run picks them before then"
2638
2861
  " (<code>vision_from</code> in <code>config/suite.json</code>; remove it to start them now).</p>"
2639
2862
  if v["held"] else "")
2640
- return ("<div class='panel'><h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures with"
2863
+ return (f"<div class='panel'>{SHARE}<h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures with"
2641
2864
  " a known answer, then real work on images and how much of it was actually checked.</p>" + held +
2642
2865
  "<h3 class='chart-head'>On the vision tasks, field by field</h3><div class='chart' id='chart-vision-fields'></div>"
2643
2866
  "<h3 class='chart-head'>On real work with images, by how each seat was judged</h3>"
@@ -2671,6 +2894,735 @@ def perf_charts() -> dict:
2671
2894
  "speed": speed}
2672
2895
 
2673
2896
 
2897
+ # ---- Trend: does a model score lower, or higher, than it used to? ----------
2898
+ #
2899
+ # From the run notes, a model against itself, task by task: a run asks up to
2900
+ # six tasks and tasks differ in size, so run totals are never compared. A score
2901
+ # is a share of the task's findings; a task whose count of findings changed
2902
+ # (its answer key did) starts again. Not a score (timeout, error, refused): left
2903
+ # out, as uptime counts those. Shown only: it routes and retires nothing.
2904
+
2905
+ TREND_DAYS = (30, 60) # the window's choices, the first by default
2906
+ TREND_MOVE = 0.10 # a move of 10 points of a task's findings, on average
2907
+ TREND_STATES = ("lower", "higher", "steady", "too little")
2908
+
2909
+
2910
+ _trend_seen: tuple = (None, {}) # (notes' stats, renames, today), _trend_rows()
2911
+
2912
+
2913
+ def trend_rows() -> dict:
2914
+ """The trend, worked out again only when a note, a rename or the day
2915
+ changes. Shared: read it, never change it."""
2916
+ global _trend_seen
2917
+ stats, rows = _note_table()
2918
+ key, seen = (stats, aliases.history(), datetime.date.today()), _trend_seen
2919
+ if stats is None: # a note could not be read: not kept
2920
+ return _trend_rows(rows, key[2])
2921
+ if seen[0] != key:
2922
+ seen = _trend_seen = (key, _trend_rows(rows, key[2]))
2923
+ return seen[1]
2924
+
2925
+
2926
+ def _trend_rows(rows: list[tuple], today: datetime.date) -> dict:
2927
+ """{"first": the first day with a score, "models": alias -> {"last": day,
2928
+ "compared": a task scored on two days or more, "windows": {days:
2929
+ summary}}}. A window's summary is the scores in the window against those
2930
+ before it. A task asked twice in a day counts once that day, as the mean
2931
+ of its scores."""
2932
+ by: dict[str, dict[tuple, dict]] = {} # alias -> (task, of) -> day -> [found], oldest first
2933
+ first = ""
2934
+ for day, _run, task, alias, found, _inv, _s in rows:
2935
+ hit = re.fullmatch(r"(\d+)/(\d+)", found)
2936
+ if not day or not hit or not int(hit[2]):
2937
+ continue
2938
+ first = min(first or day, day)
2939
+ by.setdefault(current_alias(alias), {}).setdefault((task, int(hit[2])), {}).setdefault(day, []).append(int(hit[1]))
2940
+
2941
+ def mean(xs):
2942
+ return sum(xs) / len(xs)
2943
+ models = {}
2944
+ for alias, found_by_task in by.items():
2945
+ tasks = {(task, of): [(d, mean(f) / of) for d, f in days.items()] # [(day, share)], oldest first
2946
+ for (task, of), days in found_by_task.items()}
2947
+ windows = {}
2948
+ for w in TREND_DAYS:
2949
+ since = (today - datetime.timedelta(days=w - 1)).isoformat()
2950
+ moves, recent, earlier, per = [], {}, set(), [] # per task; day -> [moves]; days before; [task, move]
2951
+ for (task, _of), scores in tasks.items():
2952
+ old = [s for d, s in scores if d < since]
2953
+ new = [(d, s) for d, s in scores if d >= since]
2954
+ if not old or not new:
2955
+ continue
2956
+ base = mean(old)
2957
+ moves.append(mean([s for _d, s in new]) - base)
2958
+ per.append([task, moves[-1]])
2959
+ earlier |= {d for d, _s in scores if d < since}
2960
+ for d, s in new:
2961
+ recent.setdefault(d, []).append(s - base)
2962
+ down, up = sum(m < -1e-9 for m in moves), sum(m > 1e-9 for m in moves)
2963
+ change = mean(moves) if moves else 0.0
2964
+ runs_down = sum(mean(v) < -1e-9 for v in recent.values())
2965
+ runs_up = sum(mean(v) > 1e-9 for v in recent.values())
2966
+ if len(moves) < 3 or len(recent) < 2 or len(earlier) < 2: # one run on either side is not a trend
2967
+ state = "too little"
2968
+ elif change <= -TREND_MOVE + 1e-9 and down >= 2 and down >= 2 * up and runs_down >= 2:
2969
+ state = "lower"
2970
+ elif change >= TREND_MOVE - 1e-9 and up >= 2 and up >= 2 * down and runs_up >= 2:
2971
+ state = "higher"
2972
+ else:
2973
+ state = "steady"
2974
+ windows[w] = {"state": state, "change": change, "tasks": len(moves), "down": down, "up": up,
2975
+ "runs": len(recent), "runsDown": runs_down, "runsUp": runs_up, "before": len(earlier),
2976
+ "per": sorted(per, key=lambda p: p[1])}
2977
+ models[alias] = {"last": max(d for s in tasks.values() for d, _s in s),
2978
+ "compared": any(len(s) > 1 for s in tasks.values()), "windows": windows}
2979
+ return {"first": first, "models": models}
2980
+
2981
+
2982
+ def _trend_says(s: dict) -> str:
2983
+ """A window's summary in words, as the chart and the table say it."""
2984
+ if not s["tasks"]:
2985
+ return "no task scored both in the window and before it"
2986
+ head = {"too little": "too little to say", "steady": "steady"}.get(s["state"], s["state"])
2987
+ return (f"{head}: {s['change'] * 100:+.0f} pts on {s['tasks']} task{'s' * (s['tasks'] != 1)}"
2988
+ f" ({s['down']} down, {s['up']} up), {s['runs']} run{'s' * (s['runs'] != 1)}"
2989
+ f" against {s['before']} before")
2990
+
2991
+
2992
+ def _trend_order(m: str, s: dict) -> tuple:
2993
+ """The chart's and the table's order: lower, higher, steady, too little;
2994
+ the biggest move first in each (too little: by name, its move means nothing)."""
2995
+ return TREND_STATES.index(s["state"]), 0 if s["state"] == "too little" else -abs(s["change"]), m
2996
+
2997
+
2998
+ def trend_data() -> dict:
2999
+ """For bench.js: registered models with a run to compare, each window's
3000
+ flag, its evidence and each task's move."""
3001
+ t, live = trend_rows(), on_bench()
3002
+ return {"choices": list(TREND_DAYS), "move": TREND_MOVE,
3003
+ "models": [{"name": m, "last": v["last"],
3004
+ "w": {str(w): {**s, "says": _trend_says(s)} for w, s in v["windows"].items()}}
3005
+ for m, v in sorted(t["models"].items()) if m in live and v["compared"]]}
3006
+
3007
+
3008
+ def trend_card() -> str:
3009
+ """Each model against its own history: a bar for how far its scores in the
3010
+ window moved from its scores before it, grouped by the flag, the evidence beside it."""
3011
+ t, live, first, names = trend_rows(), current(), TREND_DAYS[0], {}
3012
+
3013
+ def name(model: str) -> str: # each window's table shows it: worked out once
3014
+ if model not in names:
3015
+ names[model] = f"{tr(model)}<td>{named(model)}</td>"
3016
+ return names[model]
3017
+
3018
+ css = {"lower": "err", "higher": "ok", "steady": "", "too little": ""}
3019
+
3020
+ def window(w: int) -> str:
3021
+ rows = {m: v["windows"][w] for m, v in t["models"].items() if m in live and v["windows"][w]["tasks"]}
3022
+ body = ["<table><thead><tr>", th("Model"), th("Trend"), th("Change", True), th("Tasks compared", True),
3023
+ th("Down", True), th("Up", True), th("Runs in window", True), th("Runs before", True),
3024
+ th("Last scored"), "</tr></thead><tbody>"]
3025
+ for m in sorted(rows, key=lambda m: _trend_order(m, rows[m])):
3026
+ s = rows[m]
3027
+ body.append(
3028
+ name(m) + f"<td data-sort='{TREND_STATES.index(s['state'])}'>"
3029
+ + chip(s["state"], css[s["state"]], _trend_says(s)) + "</td>"
3030
+ f"<td class='num' data-sort='{s['change']:.4f}'>{s['change'] * 100:+.0f} pts</td>"
3031
+ f"<td class='num'>{s['tasks']}</td><td class='num'>{s['down']}</td><td class='num'>{s['up']}</td>"
3032
+ f"<td class='num' data-sort='{s['runs']}'>{s['runs']}"
3033
+ f" <span class='muted'>({s['runsDown']} below, {s['runsUp']} above)</span></td>"
3034
+ f"<td class='num'>{s['before']}</td><td class='mono'>{esc(t['models'][m]['last'])}</td></tr>")
3035
+ if not rows:
3036
+ body.append(f"<tr><td colspan='9' class='muted'>No model was scored on the same task both in the last"
3037
+ f" {w} days and before them.</td></tr>")
3038
+ body.append("</tbody></table>")
3039
+ return f"<div data-trend-window='{w}'{'' if w == first else ' hidden'}>" + "".join(body) + "</div>"
3040
+
3041
+ legend = ("<div class='q-legend'><span><i style='background:var(--err)'></i>lower than it used to be</span>"
3042
+ "<span><i style='background:var(--ok)'></i>higher</span><span><i style='background:var(--muted);opacity:.6'></i>"
3043
+ "steady</span><span><i style='background:var(--line)'></i>within 10 pts of its earlier scores</span>"
3044
+ "<span>no bar: too little to say</span></div>")
3045
+ pick = ("<div class='toolbar'><label>Window <select data-trend-days>"
3046
+ + "".join(f"<option value='{w}'>last {w} days</option>" for w in TREND_DAYS)
3047
+ + f"</select></label></div>{legend}")
3048
+ key = f"""<dl>
3049
+ <dt>A bar</dt><dd>How far a model's scores in the window sit from its own scores before it, on the tasks
3050
+ scored in both: each task as a share of its findings, so a 1-finding claim and an 11-finding file count
3051
+ the same, then averaged over those tasks. Left is lower, right is higher; the shaded band is 10 points
3052
+ either side. Hover for each task's move.</dd>
3053
+ <dt>The groups</dt><dd><b>Lower</b> (or <b>higher</b>) when, on average, the scores moved 10 points or
3054
+ more, on at least two tasks and twice as many as moved the other way, in at least two separate runs.
3055
+ <b>Steady</b> otherwise: a steady bar past the band moved on average, but not on enough tasks or runs.
3056
+ <b>Too little to say</b>, with no bar, when fewer than three tasks can be compared, or fewer than two
3057
+ runs in the window or before it. One bad run, or one task, is never a trend, and nor is one run to
3058
+ compare against. Biggest move first in each group.</dd>
3059
+ <dt>Beside the bar</dt><dd>The evidence: the tasks compared and how many went down or up, then the runs
3060
+ in the window against the runs before it. A model with no task scored both in the window and before it
3061
+ is named under the chart.</dd>
3062
+ <dt>The window</dt><dd>{first} days by default: a paid model is scored about once a week and a free one
3063
+ about every 15 days, six tasks a run, so 7 days holds one run at most and could only say what one
3064
+ afternoon did. {first} days holds 2 to 4 runs. Longer windows hold more runs but leave less before them
3065
+ to compare with (the scores start {esc(t['first'] or 'with the first run')}).</dd>
3066
+ <dt>Runs</dt><dd>Days a model was scored (a task asked twice in a day counts once, as the mean of its
3067
+ scores). In the window, how many sat below or above its earlier scores.</dd>
3068
+ <dt>What is counted</dt><dd>Scores as the run notes recorded them (<code>runs/suite-*.md</code>), under
3069
+ each model's current name; timeouts, errors and refusals are not scores and are left out (Uptime counts
3070
+ them). A task whose number of findings changed starts again. The answers behind the scores are kept
3071
+ from 2026-11-08 in <code>runs/answers/&lt;task&gt;/&lt;model&gt;/</code>; older ones were replaced by
3072
+ the next run and are gone.</dd>
3073
+ </dl>"""
3074
+ return chart_card("Trend", "Who scores lower, or higher, than it used to? Each model against its own earlier"
3075
+ " scores on the same tasks; flagged only when the move holds over several tasks and runs.",
3076
+ "chart-trend", "".join(window(w) for w in TREND_DAYS), key, extra=pick)
3077
+
3078
+
3079
+ # ---- Uptime: was the provider there when the bench called it? --------------
3080
+ #
3081
+ # Only calls the bench already makes, each counted once: the daily probe
3082
+ # (perf/history.csv), every gateway seat (runs/gateway/calls.jsonl), and the
3083
+ # calls only the client's ledger has (the suite's, onboarding's). The ledger
3084
+ # also lists the probe's and the gateway's calls, which are counted from their
3085
+ # own logs because those keep the error text. Each call is classified from its
3086
+ # text when the report is drawn (suite/run.uptime_class). Shown only: uptime
3087
+ # changes no routing and retires nothing.
3088
+
3089
+ UPTIME_DAYS = (7, 30) # the window's choices, the first by default
3090
+ LEDGER_OWN = ("perf/run", "serve", "gateway") # callers whose calls their own logs count
3091
+ # A probe row from before 2026-10-16 keeps no text, only its class then.
3092
+ _OLD_CLASS = {"empty": "empty", "capacity": "busy", "structural": "setup",
3093
+ "paywalled": "setup", "entitlement": "setup", "signin": "setup"}
3094
+ _OUT_LABEL = {"quota": "our quota", "rate": "our rate limit", "notsent": "not sent by the bench",
3095
+ "client": "our client", "relay": "our LAN hub"}
3096
+ _ledger_seen: dict = {"marks": {}, "rows": {}, "mute": {}, "conn": {}, "endpoint": {}}
3097
+ _ledger_lock = threading.Lock()
3098
+ _LEDGER_HEAD = re.compile(r'"t":([\d.]+),"at":"([^"]*)","alias":"([^"]*)","connection":"([^"]*)","endpoint":"([^"]*)"')
3099
+
3100
+
3101
+ def _ledger_calls(since_month: str) -> tuple[list, dict, dict, float]:
3102
+ """(calls only the ledger has, alias -> connection, alias -> endpoint, the
3103
+ start of the last failed call it kept no text for) from the month files
3104
+ from `since_month` (YYYY-MM) on. A call is (t, at, alias, connection,
3105
+ status, error, seconds). New lines only, as the gateway's seats are read."""
3106
+ import ledger
3107
+ with _ledger_lock:
3108
+ seen = _ledger_seen
3109
+ for path in sorted(ledger.DIR.glob("*.jsonl")):
3110
+ stem = path.stem
3111
+ if stem < since_month:
3112
+ continue
3113
+ fresh, lines = locked.appended(path, seen["marks"].setdefault(stem, {}))
3114
+ if fresh:
3115
+ seen["rows"][stem], seen["mute"][stem] = [], 0.0
3116
+ rows = seen["rows"].setdefault(stem, [])
3117
+ for raw in lines:
3118
+ # Most lines are the probe's or the gateway's, wanted only for
3119
+ # where the model was: read by a pattern, not parsed (ledger.append
3120
+ # writes the keys in this order).
3121
+ if (m := _LEDGER_HEAD.search(raw)) and any(f'"caller":"{c}"' in raw for c in LEDGER_OWN):
3122
+ t, at, alias, conn, endpoint = float(m[1]), m[2], m[3], m[4], m[5]
3123
+ status, mute = None, '"status":"error"' in raw and '"error":' not in raw
3124
+ else:
3125
+ try:
3126
+ r = json.loads(raw)
3127
+ except ValueError:
3128
+ continue
3129
+ if not isinstance(r, dict) or not r.get("alias") or not isinstance(r.get("t"), (int, float)):
3130
+ continue
3131
+ t, at, alias, conn, endpoint = r["t"], r.get("at") or "", r["alias"], r.get("connection"), r.get("endpoint")
3132
+ status = (r.get("status") or "") if r.get("caller") not in LEDGER_OWN else None
3133
+ mute = r.get("status") == "error" and "error" not in r
3134
+ if conn not in (None, "", "?"):
3135
+ seen["conn"][alias] = conn
3136
+ if endpoint:
3137
+ seen["endpoint"][alias] = endpoint
3138
+ if mute:
3139
+ seen["mute"][stem] = max(seen["mute"].get(stem, 0.0), t)
3140
+ if status is not None:
3141
+ rows.append((t, at, alias, conn or "", status, r.get("error") or "", r.get("seconds")))
3142
+ stems = sorted(s for s in seen["rows"] if s >= since_month)
3143
+ # Until the ledger kept error text (2026-11-08) its failed calls cannot be
3144
+ # classified, and counting only its successes would flatter everyone: its
3145
+ # own calls count from after the last failure it kept no text for.
3146
+ mute = max((seen["mute"].get(s, 0.0) for s in stems), default=0.0)
3147
+ return ([c for s in stems for c in seen["rows"][s]], dict(seen["conn"]), dict(seen["endpoint"]), mute)
3148
+
3149
+
3150
+ @functools.lru_cache(maxsize=8192)
3151
+ def _uptime_class(text: str, status: str, private: bool, decided: tuple = ()) -> tuple[str, str]:
3152
+ """`decided`: metrics/decided.json's stat, so a new decision about a
3153
+ shape (decide.classify_errors) is read on the next render, not the next start."""
3154
+ from run import uptime_class
3155
+ return uptime_class(text, status, private)
3156
+
3157
+
3158
+ def _down(kind: str) -> bool:
3159
+ """A kind of failure counted down: its class, before any "(decided by ...)"."""
3160
+ from run import UPTIME_DOWN
3161
+ return kind.split(" (decided by ")[0] in UPTIME_DOWN
3162
+
3163
+
3164
+ _decide_seen: dict = {"mark": {}, "rows": []}
3165
+ _decide_lock = threading.Lock()
3166
+
3167
+
3168
+ def _decision_calls() -> list[tuple]:
3169
+ """Every decision call (metrics/decisions.jsonl, decide.call): (at, alias,
3170
+ served, via, status, error, seconds). New lines only, as the ledger is read."""
3171
+ with _decide_lock:
3172
+ fresh, lines = locked.appended(decide.LOG, _decide_seen["mark"])
3173
+ if fresh:
3174
+ _decide_seen["rows"] = []
3175
+ for raw in lines:
3176
+ try:
3177
+ r = json.loads(raw)
3178
+ except ValueError:
3179
+ continue
3180
+ if isinstance(r, dict) and r.get("at") and r.get("alias"):
3181
+ # Answered unless it failed: a reply without the asked answer is still the provider there.
3182
+ status = "error" if r.get("error") else "ok"
3183
+ _decide_seen["rows"].append((r["at"], r["alias"], r.get("served") or "", r.get("via") or "",
3184
+ status, r.get("error") or "", r.get("seconds")))
3185
+ return list(_decide_seen["rows"])
3186
+
3187
+
3188
+ def _shape(text: str) -> str:
3189
+ """An error as a shape, so the same failure counts once in a list: URLs,
3190
+ ids and numbers stand in for what changes between calls."""
3191
+ s = re.sub(r"https?://\S+", "<url>", text)
3192
+ s = re.sub(r"\b(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{14,}\b", "<id>", s)
3193
+ return " ".join(re.sub(r"\d+(\.\d+)?", "N", s).split())[:130]
3194
+
3195
+
3196
+ def uptime_rows() -> dict:
3197
+ """The Uptime report, once a render (the card and the charts both ask)."""
3198
+ return locked.once(("uptime",), _uptime_rows)
3199
+
3200
+
3201
+ def _uptime_rows() -> dict:
3202
+ """{"days": the last 30 dates, "providers"/"models": name -> one bucket a
3203
+ day (None, or up, down, out, {class: n}, [answered seconds], last down),
3204
+ "conn": model -> connection, "odd": unrecognised shapes [day index, at,
3205
+ connection, shape]}. A provider's uptime is weighted by calls: every call
3206
+ to any of its models counts once."""
3207
+ import ipaddress
3208
+ import urllib.parse
3209
+ from run import UPTIME_DOWN, UPTIME_UP
3210
+ today = datetime.date.today()
3211
+ days = [(today - datetime.timedelta(days=i)).isoformat() for i in range(UPTIME_DAYS[-1] - 1, -1, -1)]
3212
+ since, index = days[0], {d: i for i, d in enumerate(days)}
3213
+ named_now: dict[str, str] = {}
3214
+
3215
+ def now(alias: str) -> str:
3216
+ if alias not in named_now:
3217
+ named_now[alias] = current_alias(alias)
3218
+ return named_now[alias]
3219
+ # The month before too: a file is a UTC month, a day here is local.
3220
+ calls, conn_of, endpoint_of, mute = _ledger_calls((today - datetime.timedelta(days=UPTIME_DAYS[-1])).isoformat()[:7])
3221
+ conn_of = {now(a): c for a, c in conn_of.items()} # a renamed model keeps where it was
3222
+ endpoint_of = {now(a): e for a, e in endpoint_of.items()}
3223
+ data = endpoints.load()
3224
+ regs = endpoints.registrations()
3225
+ gone = retired()
3226
+ conns: dict[str, str] = {}
3227
+ private: dict[str, bool] = {}
3228
+
3229
+ def conn(alias: str) -> str:
3230
+ if alias not in conns:
3231
+ c = conn_of.get(alias) or catalog.connection_of(alias)
3232
+ if c in ("", "?"):
3233
+ c = (gone.get(alias, {}).get("tag") or "").split("/")[0] or "unknown"
3234
+ conns[alias] = c
3235
+ return conns[alias]
3236
+
3237
+ def lan(alias: str) -> bool:
3238
+ """The call went to a hub on our own network (a private or loopback address)."""
3239
+ if alias not in private:
3240
+ base = (regs.get(alias, {}).get("api_base")
3241
+ or data.get(endpoint_of.get(alias, ""), {}).get("base") or "")
3242
+ host = urllib.parse.urlsplit(base).hostname or ""
3243
+ try:
3244
+ private[alias] = ipaddress.ip_address(host).is_private
3245
+ except ValueError:
3246
+ private[alias] = host == "localhost" or host.endswith((".local", ".lan", ".localhost"))
3247
+ return private[alias]
3248
+
3249
+ agg: dict[tuple, list] = {} # (day, alias, connection, class, why) -> [calls, answered seconds, last at]
3250
+ odd: list[list] = []
3251
+ try:
3252
+ st = decide.DECIDED.stat()
3253
+ stamp = (st.st_mtime_ns, st.st_size)
3254
+ except OSError:
3255
+ stamp = ()
3256
+
3257
+ def classify(text: str, status: str, private: bool) -> tuple[str, str]:
3258
+ return _uptime_class(text, status, private, stamp)
3259
+
3260
+ def count(at: str, alias: str, c: str, cls: str, why: str, n: int = 1, seconds=(), text: str = "") -> None:
3261
+ day = index.get(at[:10])
3262
+ if day is None or n <= 0:
3263
+ return
3264
+ a = agg.get(k := (day, alias, c, cls, why))
3265
+ if a is None:
3266
+ a = agg[k] = [0, [], ""]
3267
+ a[0] += n
3268
+ if cls == "ok":
3269
+ a[1] += seconds
3270
+ a[2] = max(a[2], at)
3271
+ # No rule knows it: listed, with what a decision model said of it when one did.
3272
+ if cls == "unrecognised" or why.startswith("decided by"):
3273
+ odd.append([day, at, c, n, _shape(text) if text else f"({why})",
3274
+ f"{cls}, {why}" if why.startswith("decided by") else ""])
3275
+
3276
+ for r in perf_rows(): # the probe: counts per row, one text per row
3277
+ at, alias = r.get("timestamp") or "", r["model"]
3278
+ # Not the dashboard's Retry: it writes a row only when the model answered.
3279
+ if at[:10] < since or r.get("prompt") == "retry":
3280
+ continue
3281
+ c, num = conn(alias), (lambda k: int(r.get(k) or 0))
3282
+ p50 = float(r.get("p50_s") or 0) # of the answers; a refusal is not timed
3283
+ count(at, alias, c, "ok", "", num("answered") + num("refused"), [p50] * num("answered") if p50 > 0 else [])
3284
+ count(at, alias, c, "timeout", "timed out", num("timeout"))
3285
+ if num("error"):
3286
+ old = r.get("failure") or ""
3287
+ if old == "timeout":
3288
+ # The row's one text is its timeout's; its errors were worse-than-nothing
3289
+ # classes after it (empty, other) that kept no text.
3290
+ cls, why = "unrecognised", "no text kept; the probe's text was its timeout's"
3291
+ elif r.get("detail"):
3292
+ cls, why = classify(r["detail"], "error", lan(alias))
3293
+ else:
3294
+ cls, why = _OLD_CLASS.get(old, "unrecognised"), f"no text kept; the probe said {old or 'nothing'}"
3295
+ count(at, alias, c, cls, why, num("error"), text=r.get("detail") if old != "timeout" else "")
3296
+ for (_day, alias, status, error), (n, seconds, last) in _seat_counts(since).items():
3297
+ alias = now(alias)
3298
+ cls, why = classify(error, status, lan(alias))
3299
+ count(last, alias, conn(alias), cls, why, n, seconds, error)
3300
+ for t, at, alias, c, status, error, seconds in calls: # the suite's and the rest, once text is kept
3301
+ if t <= mute or at[:10] < since:
3302
+ continue
3303
+ alias = now(alias)
3304
+ cls, why = classify(error, status, lan(alias))
3305
+ count(at, alias, c or conn(alias), cls, why, 1, [float(seconds)] if seconds else [], error)
3306
+ # Decision models: every call decide.call logged, on the connection behind its served name
3307
+ # (provider tags: OpenRouter for the :free ones), its hub's address deciding the relay.
3308
+ tags, hubs = load_json(DATA / "config" / "provider-tags.json"), {}
3309
+ for at, alias, served, via, status, error, seconds in _decision_calls():
3310
+ if at[:10] < since:
3311
+ continue
3312
+ alias = now(alias) # a renamed decision model keeps its calls
3313
+ if via not in hubs:
3314
+ hubs[via] = decide._private(via)
3315
+ cls, why = classify(error, status, hubs[via])
3316
+ c = str(tags.get(served) or "").split("/")[0] or via or "unknown"
3317
+ conns.setdefault(alias, c)
3318
+ count(at, alias, c, cls, why, 1, [float(seconds)] if seconds and cls == "ok" else [], error)
3319
+ providers: dict[str, list] = {}
3320
+ models: dict[str, list] = {}
3321
+ for (day, alias, c, cls, why), (n, seconds, last) in agg.items():
3322
+ kind = f"setup: {why}" if cls == "setup" else _OUT_LABEL.get(cls, cls)
3323
+ if why.startswith("decided by") and cls != "setup":
3324
+ kind += f" ({why})" # the hover says a model, not a rule, said so
3325
+ for table, key in ((providers, c), (models, alias)):
3326
+ cells = table.setdefault(key, [None] * len(days))
3327
+ b = cells[day] = cells[day] or [0, 0, 0, {}, [], ""]
3328
+ if cls in UPTIME_UP:
3329
+ b[0] += n
3330
+ b[4] += seconds
3331
+ continue
3332
+ b[3][kind] = b[3].get(kind, 0) + n
3333
+ if cls in UPTIME_DOWN:
3334
+ b[1] += n
3335
+ b[5] = max(b[5], last)
3336
+ else:
3337
+ b[2] += n
3338
+ return {"days": days, "providers": providers, "models": models,
3339
+ "conn": {m: conn(m) for m in models}, "odd": odd}
3340
+
3341
+
3342
+ def _uptime_window(cells: list, days: int) -> dict:
3343
+ """One row's totals over the last `days`."""
3344
+ up = down = out = 0
3345
+ kinds: dict[str, int] = {}
3346
+ seconds: list[float] = []
3347
+ last = ""
3348
+ for b in cells[-days:]:
3349
+ if b:
3350
+ up, down, out, last = up + b[0], down + b[1], out + b[2], max(last, b[5])
3351
+ seconds += b[4]
3352
+ for k, n in b[3].items():
3353
+ kinds[k] = kinds.get(k, 0) + n
3354
+ seconds.sort()
3355
+ return {"up": up, "down": down, "out": out, "kinds": kinds, "last": last,
3356
+ "pct": up / (up + down) if up + down else None,
3357
+ "median": seconds[len(seconds) // 2] if seconds else None}
3358
+
3359
+
3360
+ def uptime_cell(cells: list | None, days: int = UPTIME_DAYS[0]) -> str:
3361
+ """At a glance's Uptime column: answered of counted over the last `days`."""
3362
+ t = _uptime_window(cells or [], days)
3363
+ if t["pct"] is None:
3364
+ return "<td class='num muted' data-sort='-1' title='no counted calls'>—</td>"
3365
+ return (f"<td class='num{' err' if t['pct'] < .8 else ''}' data-sort='{t['pct']:.4f}'"
3366
+ f" title='{t['up']} of {t['up'] + t['down']} answered'>{t['pct'] * 100:.1f}%</td>")
3367
+
3368
+
3369
+ def _why(kinds: dict, which) -> str:
3370
+ return ", ".join(f"{k} {n}" for k, n in sorted(kinds.items(), key=lambda kv: -kv[1]) if which(k))
3371
+
3372
+
3373
+ def uptime_data() -> dict:
3374
+ """For bench.js: each row's 30 days, a day as [up, down, left out, why] or 0.
3375
+ Models on the bench now, decision models included."""
3376
+ u = uptime_rows()
3377
+ live = on_bench() | set(decide.load())
3378
+
3379
+ def row(name: str, cells: list) -> dict:
3380
+ out = []
3381
+ for b in cells:
3382
+ if not b:
3383
+ out.append(0)
3384
+ continue
3385
+ down = _why(b[3], _down)
3386
+ left = _why(b[3], lambda k: not _down(k))
3387
+ out.append([b[0], b[1], b[2], "; ".join(filter(None, [down and "down: " + down,
3388
+ left and "left out: " + left]))])
3389
+ return {"name": name, "cells": out}
3390
+ return {"days": u["days"], "choices": list(UPTIME_DAYS),
3391
+ "providers": [row(c, cells) for c, cells in sorted(u["providers"].items())],
3392
+ "models": [row(m, cells) for m, cells in sorted(u["models"].items()) if m in live]}
3393
+
3394
+
3395
+ def uptime_card() -> str:
3396
+ """Was the provider there when the bench called it: a cell a day, by
3397
+ provider then by model; 7 days, or 30 on choice (bench.js swaps them)."""
3398
+ u = uptime_rows()
3399
+ first, gone, names = UPTIME_DAYS[0], retired(), {}
3400
+
3401
+ def name(model: str) -> str: # each window's table shows it: worked out once
3402
+ if model not in names:
3403
+ names[model] = (("<tr class='retired'>" if model in gone else "<tr>")
3404
+ + f"<td>{named(model)}</td><td>{esc(u['conn'].get(model, ''))}</td>")
3405
+ return names[model]
3406
+
3407
+ def window(w: int) -> str:
3408
+ parts = []
3409
+ for title, rows, models in (("Providers", u["providers"], False), ("Models", u["models"], True)):
3410
+ totals = {k: _uptime_window(cells, w) for k, cells in rows.items()}
3411
+ totals = {k: t for k, t in totals.items() if t["up"] + t["down"] + t["out"]}
3412
+ body = ["<table><thead><tr>", th("Model" if models else "Provider"), th("Connection") if models else "",
3413
+ th("Uptime"), th("Calls counted", True), th("Down"), th("Left out"), th("Reply time", True),
3414
+ th("Last down"), "</tr></thead><tbody>"]
3415
+ for k in sorted(totals, key=lambda k: (2 if totals[k]["pct"] is None else totals[k]["pct"], k)):
3416
+ t, pct = totals[k], totals[k]["pct"]
3417
+ meter = (f"<span class='helped'><span class='meter'><span{' class=low' if pct < .8 else ''}"
3418
+ f" style='width:{max(4, round(pct * 100))}%'></span></span>"
3419
+ f"<span class='mono'>{pct * 100:.1f}%</span></span>" if pct is not None
3420
+ else "<span class='muted'>no counted calls</span>")
3421
+ down = _why(t["kinds"], _down)
3422
+ left = _why(t["kinds"], lambda kind: not _down(kind))
3423
+ body.append(
3424
+ (name(k) if models else f"<tr><td>{esc(k)}</td>")
3425
+ + f"<td data-sort='{-1 if pct is None else round(pct, 4)}'>{meter}</td>"
3426
+ f"<td class='num'>{t['up'] + t['down']}</td>"
3427
+ f"<td class='why' data-sort='{t['down']}'>{t['down']}"
3428
+ + (f" <span class='muted'>· {esc(down)}</span>" if down else "") + "</td>"
3429
+ f"<td class='why' data-sort='{t['out']}'>{t['out']}"
3430
+ + (f" <span class='muted'>· {esc(left)}</span>" if left else "") + "</td>"
3431
+ + (f"<td class='num' data-sort='{t['median']}'>{t['median']:.1f}s</td>" if t["median"]
3432
+ else "<td class='num muted' data-sort='99999'>—</td>")
3433
+ + (f"<td class='mono' data-sort='{t['last']}'>{esc(t['last'][5:16].replace('T', ' '))}</td>"
3434
+ if t["last"] else "<td class='muted' data-sort=''>—</td>") + "</tr>")
3435
+ body.append("</tbody></table>")
3436
+ parts.append(f"<h3 class='chart-head'>{title}</h3>" + "".join(body))
3437
+ return (f"<div data-uptime-window='{w}'{'' if w == first else ' hidden'}>" + "".join(parts) + "</div>")
3438
+
3439
+ def odd(w: int) -> str:
3440
+ shapes: dict[str, list] = {}
3441
+ for day, at, conn, n, shape, said in u["odd"]:
3442
+ if day >= len(u["days"]) - w:
3443
+ # By shape and what was decided of it: two texts this shape joins may not share a decision.
3444
+ s = shapes.setdefault((shape, said), [0, set(), at, at, said])
3445
+ s[0] += n
3446
+ s[1].add(conn)
3447
+ s[2], s[3] = min(s[2], at), max(s[3], at)
3448
+ if not shapes:
3449
+ return ""
3450
+ items = "".join(
3451
+ f"<li><span class='mono'>{n}×</span> {esc(shape)} <span class='muted'>· {esc(', '.join(sorted(conns)))},"
3452
+ f" {esc(a[5:10])}{'' if a[:10] == b[:10] else ' to ' + esc(b[5:10])}</span>"
3453
+ + (" {} <span class='muted'>{}</span>".format(
3454
+ chip(said.partition(", ")[0], "warn", "no rule knows it; counted as " + said),
3455
+ esc(said.partition(", ")[2])) if said else "") + "</li>"
3456
+ for (shape, _), (n, conns, a, b, said) in sorted(shapes.items(), key=lambda kv: -kv[1][0])[:12])
3457
+ decided, total = sum(s[0] for s in shapes.values() if s[4]), sum(s[0] for s in shapes.values())
3458
+ return (f"<div data-uptime-window='{w}'{'' if w == first else ' hidden'}>"
3459
+ f"<h3 class='chart-head'>Not recognised by a rule: {total} call{'s' * (total != 1)}"
3460
+ + (f", {decided} of them classed by a decision model" if decided else "") + "</h3>"
3461
+ "<p class='hint'>Counted as down until a rule in <code>suite/run.py</code>"
3462
+ " (<code>uptime_class</code>) says what they are, unless a decision model was asked about the"
3463
+ " shape (the scheduler's <code>decisions</code> job, a few new shapes a day): then counted as it"
3464
+ " said, marked here and in the hovers with the model and how sure it was.</p>"
3465
+ f"<ul class='links'>{items}</ul></div>")
3466
+
3467
+ legend = ("<div class='q-legend'><span><i style='background:var(--ok)'></i>all answered</span>"
3468
+ "<span><i style='background:var(--signal)'></i>some down, 80% or more answered</span>"
3469
+ "<span><i style='background:var(--err)'></i>under 80% answered</span>"
3470
+ "<span><i style='background:none;border:1px solid var(--line)'></i>no counted calls</span></div>")
3471
+ pick = ("<div class='toolbar'><label>Days <select data-uptime-days>"
3472
+ + "".join(f"<option value='{w}'>last {w}</option>" for w in UPTIME_DAYS)
3473
+ + f"</select></label></div>{legend}")
3474
+ key = """<dl>
3475
+ <dt>Down</dt><dd>The provider was busy or overloaded (a 5xx, high demand, a shared pool's
3476
+ 429), unreachable (a broken stream, no reply), timed out (the gateway's 420 s included), or
3477
+ failed in a way no rule recognises yet, listed under the chart so a rule can be added. A
3478
+ shape a decision model was asked about counts as it said, marked "decided by" with the model
3479
+ and how sure it was (at 0.6 or more; below that it stays unrecognised).</dd>
3480
+ <dt>Left out</dt><dd>Neither up nor down, since it says nothing about the provider: our own
3481
+ quota or allowance used up (a day, a month, credits), our own per-minute or concurrency
3482
+ limit, a call the bench's pacing never sent, our client failing, our LAN hub refusing the
3483
+ connection, and setup problems (sign-in, no such model, no access, our own request).</dd>
3484
+ <dt>Up</dt><dd>It answered. An empty reply still counts as an answer.</dd>
3485
+ <dt>A cell</dt><dd>One day: answered of the calls counted that day. Hollow when nothing was
3486
+ counted that day; hover for the day, the count and the main reasons.</dd>
3487
+ <dt>The % and (n)</dt><dd>Answered of counted over the days shown, and how many were counted:
3488
+ weighted by calls, so a provider's row is every call to any of its models.</dd>
3489
+ <dt>What is counted</dt><dd>Only calls the bench makes anyway: the daily probe, every gateway
3490
+ seat, the suite's own calls once the client's ledger keeps their error text
3491
+ (2026-11-08 on), and every decision model call (<code>metrics/decisions.jsonl</code>), on the
3492
+ connection behind its served name. Each is classified from its error text when this is drawn,
3493
+ so a better rule changes the past too. It is shown only: nothing is routed or retired by it.</dd>
3494
+ </dl>"""
3495
+ return chart_card("Uptime", "Whether the provider was there when the bench called it: the daily probe"
3496
+ " and every real call, one cell a day. Busy, unreachable and timed out count as down;"
3497
+ " our own quota, our own pacing and setup problems are left out.",
3498
+ "chart-uptime-models", "".join(window(w) for w in UPTIME_DAYS), key,
3499
+ extra=pick + "<h3 class='chart-head'>By provider</h3>"
3500
+ "<div class='chart' id='chart-uptime-providers'></div>"
3501
+ "<h3 class='chart-head'>By model</h3>",
3502
+ after="".join(odd(w) for w in UPTIME_DAYS))
3503
+
3504
+
3505
+ # ---- Decision models: how well each answers the bench's typed questions -----
3506
+ #
3507
+ # metrics/decide.py's benchmark (the scheduler's `decisions` job) writes
3508
+ # metrics/decide-bench.json; this shows it, and which model each use goes to
3509
+ # now (decide.choose), never asking one.
3510
+
3511
+ DECIDE_USES = {"shape": "The shape of a request (gateway)", "collection": "A collection for a request that names none",
3512
+ "notice": "A short reply that may be a provider's notice (gateway seats)",
3513
+ "errors": "Error shapes no uptime rule knows (daily job)", "role": "A new chat model's role (onboarding)"}
3514
+ DECIDE_SETS = {"errors": "Error classes", "collection": "Collections", "useful": "Useful answers"}
3515
+ DECIDE_KIND = {"errors": "choice of 6", "collection": "choice of the collections", "useful": "yes or no"}
3516
+
3517
+
3518
+ def decision_data() -> dict:
3519
+ """For bench.js: each model's calibration bins per set, [said, right, n]."""
3520
+ res = decide.results()
3521
+ return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS],
3522
+ "points": [{"model": a, "set": s, "said": b["said"], "right": b["right"], "n": b["n"]}
3523
+ for a, by in sorted((res.get("models") or {}).items()) for s, r in by.items()
3524
+ if s in decide.SETS for b in r.get("calibration") or []]}
3525
+
3526
+
3527
+ def _pct(x) -> str:
3528
+ return "—" if x is None else f"{x * 100:.0f}%"
3529
+
3530
+
3531
+ def decision_card() -> str:
3532
+ """Per decision model and set: right of asked, Brier, calibration, the
3533
+ cutoff's coverage and accuracy, seconds, failures; and who each use asks."""
3534
+ res = decide.results()
3535
+ measured, onboarded, low = res.get("models") or {}, decide.load(), decide.MIN_ITEMS
3536
+ uses = []
3537
+ for u in decide.USES:
3538
+ who, why = decide.choose(u, measured)
3539
+ uses.append(f"<li>{esc(DECIDE_USES[u])}"
3540
+ + (" <span class='muted'>· per request</span>" if u in decide.PER_REQUEST else "") + ": "
3541
+ + (f"<b>{esc(who)}</b>" if who else "<span class='muted'>nobody</span>")
3542
+ + f" <span class='muted'>· {esc(why)}</span></li>")
3543
+ tables = []
3544
+ for s, title in DECIDE_SETS.items():
3545
+ info = (res.get("sets") or {}).get(s) or {}
3546
+ rows = {a: by[s] for a, by in measured.items() if isinstance(by.get(s), dict)}
3547
+ ranked = sorted((a for a, r in rows.items() if r["asked"] >= low and a in onboarded),
3548
+ key=lambda a: -(rows[a]["correct"] + 1) / (rows[a]["asked"] + 2))
3549
+ body = ["<table><thead><tr>", th("Model"), th("Rank"), th("Right of asked", True), th("Accuracy", True),
3550
+ th("Brier", True), th("Calibration gap", True), th(f"At {decide.CONFIDENT}"), th("Seconds", True),
3551
+ th("Failures"), th("Last asked"), "</tr></thead><tbody>"]
3552
+ for a in sorted(rows, key=lambda a: (a not in ranked, ranked.index(a) if a in ranked else 0, a)):
3553
+ r, n = rows[a], rows[a].get("answered", 0)
3554
+ rank = (chip(f"{ranked.index(a) + 1} of {len(ranked)}", "ok" if ranked.index(a) == 0 else "",
3555
+ f"ranked on {r['asked']} items asked") if a in ranked
3556
+ else chip(f"below {low} items", "", f"{r['asked']} asked; a model is ranked from {low}")
3557
+ if a in onboarded else chip("not onboarded", "", "no longer in config/decision-models.json"))
3558
+ acc = (f"{_pct(r.get('accuracy'))} <span class='muted'>of {n}</span>"
3559
+ + (f" <span class='muted'>· F1 {r['macro_f1']:.2f}</span>" if r.get("macro_f1") is not None else "")
3560
+ if n else "<span class='muted'>no answer</span>")
3561
+ sure = r.get("confident") or {}
3562
+ cut = (f"{_pct(sure.get('coverage'))} <span class='muted'>of {n} sure</span>, {_pct(sure.get('accuracy'))}"
3563
+ f" <span class='muted'>of {round(sure['coverage'] * n)} right</span>"
3564
+ if n and sure.get("coverage") is not None else "—")
3565
+ fails = sum((r.get("failures") or {}).values())
3566
+ body.append(
3567
+ f"<tr><td>{named(a)}</td><td>{rank}</td>"
3568
+ f"<td class='num' data-sort='{r['correct']}'>{r['correct']}/{r['asked']}</td>"
3569
+ f"<td class='num' data-sort='{r.get('accuracy') if n else -1}'>{acc}</td>"
3570
+ + (f"<td class='num' data-sort='{r['brier']}'>{r['brier']:.3f}</td>"
3571
+ f"<td class='num' data-sort='{r['ece']}'>{r['ece']:.2f}</td>" if n else
3572
+ "<td class='num muted' data-sort='9'>—</td><td class='num muted' data-sort='9'>—</td>")
3573
+ + f"<td class='why'>{cut}</td>"
3574
+ + (f"<td class='num'>{r['seconds']:.2f}</td>" if r.get("seconds") is not None
3575
+ else "<td class='num muted'>—</td>")
3576
+ + f"<td class='why' data-sort='{fails}'>{fails}"
3577
+ + (f" <span class='muted'>· {esc(_why(r['failures'], lambda k: True))}</span>" if fails else "")
3578
+ + f"</td><td class='mono'>{esc((r.get('last') or '')[5:16].replace('T', ' '))}</td></tr>")
3579
+ if not rows:
3580
+ body.append("<tr><td colspan='10' class='muted'>No model asked yet.</td></tr>")
3581
+ body.append("</tbody></table>")
3582
+ labels = ", ".join(f"{k} {v}" for k, v in (info.get("labels") or {}).items())
3583
+ tables.append(f"<h3 class='chart-head'>{title}: {info.get('size', '?')} items"
3584
+ f" <span class='muted'>({esc(DECIDE_KIND[s])}{'; ' + esc(labels) if labels else ''})</span></h3>"
3585
+ + "".join(body))
3586
+ when = res.get("updated", "")
3587
+ extra = (f"<h3 class='chart-head'>Who each use asks now</h3><ul class='links'>{''.join(uses)}</ul>"
3588
+ f"<p class='hint'>{'Measured up to ' + esc(when[:16].replace('T', ' ')) + '.' if when else 'Not measured yet.'}"
3589
+ " The scheduler's <code>decisions</code> job asks each model one question a set a day;"
3590
+ " <code>python metrics/decide.py bench</code> runs a pass by hand.</p>"
3591
+ "<h3 class='chart-head'>Calibration: said against right</h3>")
3592
+ key = f"""<dl>
3593
+ <dt>The sets</dt><dd>Questions with known answers, built from the bench's own data, no hand labelling.
3594
+ <b>Error classes</b>: failed calls' error text (redacted: no keys, ids or emails) and the class the
3595
+ uptime rules give it. <b>Collections</b>: gateway requests and the collection the caller named (not
3596
+ ones a decision model chose). <b>Useful answers</b>: seat answers you judged, with their request,
3597
+ a yes/no question.</dd>
3598
+ <dt>Right of asked</dt><dd>Each item's latest answer; a failed call counts as asked and not right.
3599
+ <b>Accuracy</b> is of the answers given; F1 is the mean over the options of how well each was
3600
+ picked, so always saying the commonest option scores low.</dd>
3601
+ <dt>Brier</dt><dd>How far its probabilities sat from the truth: 0 is perfect; for a choice it is
3602
+ summed over the options, so up to 2. Lower is better, and a sure wrong answer costs most.</dd>
3603
+ <dt>Calibration</dt><dd>The chart: answers grouped by the probability the model gave its answer
3604
+ (x), against how often those answers were right (y). On the diagonal, its 80% answers come true
3605
+ 80% of the time; under it, it is surer than it should be. A bigger dot is more answers. The gap is
3606
+ the average distance from the diagonal, weighted by answers.</dd>
3607
+ <dt>At {decide.CONFIDENT}</dt><dd>The gateway acts on a choice only when the model is this sure: how
3608
+ many answers were that sure, and how many of those were right.</dd>
3609
+ <dt>Rank</dt><dd>Among models with {low} or more items asked on the set, by right answers
3610
+ smoothed for sample size ((right + 1) / (asked + 2)). Below {low} a model is shown but not ranked,
3611
+ and a use keeps its default (the first onboarded model that takes the question).</dd>
3612
+ <dt>Who each use asks</dt><dd>The shape of a request and a new model's role are ranked on the
3613
+ collection set (one described option of several, for a text), the notice check on useful answers
3614
+ (the only yes/no set). A use asked per request never goes to a <code>:free</code> model while
3615
+ another can answer: their calls come out of OpenRouter's free daily budget, which the suite and
3616
+ the probe share.</dd>
3617
+ <dt>Failures</dt><dd>Calls that came back with no answer, by the uptime classes (setup: no such
3618
+ model, or paywalled). The pass stops a model at its first failure.</dd>
3619
+ </dl>"""
3620
+ return chart_card("Decision models", "The models that answer typed questions (a choice, a yes or no) with"
3621
+ " a probability: how often they are right on the bench's own labelled questions, how far"
3622
+ " to trust their probabilities, and which one each use asks.",
3623
+ "chart-decide", "".join(tables), key, extra=extra, retired=False)
3624
+
3625
+
2674
3626
  def bughunt_card() -> str:
2675
3627
  """Seeded and real-bug tasks against their answer keys."""
2676
3628
  rows = {m: r["hunt"] for m, r in wrong_table().items() if m in current() and any(r["hunt"].values())}
@@ -2717,7 +3669,7 @@ def work_card() -> str:
2717
3669
 
2718
3670
 
2719
3671
  def overview_card() -> str:
2720
- return ("<div class='panel'><h2>Who to trust with a review</h2><p class='sub'>Each dot is a model."
3672
+ return (f"<div class='panel'>{SHARE}<h2>Who to trust with a review</h2><p class='sub'>Each dot is a model."
2721
3673
  " Right: finds more of the real bugs in our own code. Up: makes fewer things up (confidently"
2722
3674
  " wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be.</p>"
2723
3675
  "<div class='chart' id='chart-overview'></div></div>")
@@ -2785,8 +3737,7 @@ EXTERNAL = (("LiveBench", "https://livebench.ai/", "contamination-limited questi
2785
3737
  def external_card() -> str:
2786
3738
  """Links, not scores: the External tab showed FuncBench numbers nobody
2787
3739
  refreshed and was dropped (2026-10-16). Public leaderboards are read there."""
2788
- rows = "".join(f"<li><a href='{esc(u)}' target='_blank' rel='noopener'>{esc(n)}</a>"
2789
- f" <span class='muted'>— {esc(why)}</span></li>" for n, u, why in EXTERNAL)
3740
+ rows = "".join(f"<li>{ext_link(u, n)} <span class='muted'>— {esc(why)}</span></li>" for n, u, why in EXTERNAL)
2790
3741
  return ("<div class='panel'><h2>External</h2><p class='sub'>Public leaderboards, for comparison."
2791
3742
  " The bench does not copy their numbers; its own tests above are what routing uses.</p>"
2792
3743
  f"<ul class='links'>{rows}</ul></div>")
@@ -2841,8 +3792,9 @@ def wrong_card() -> str:
2841
3792
 
2842
3793
  def benchmarks_tab() -> str:
2843
3794
  data = json.dumps(charts_data(), ensure_ascii=False).replace("</", "<\\/")
3795
+ # Trend, Uptime and Performance side by side: what moves over time, in one stretch.
2844
3796
  return (overview_card() + bughunt_card() + work_card() + wrong_card() + suite_card() + vision_card()
2845
- + perf_card() + external_card()
3797
+ + trend_card() + uptime_card() + perf_card() + decision_card() + external_card()
2846
3798
  + f"<script type='application/json' id='charts-data'>{data}</script>")
2847
3799
 
2848
3800
 
@@ -2922,7 +3874,12 @@ def favicon(theme: str) -> str:
2922
3874
  return f"<link rel='icon' type='image/svg+xml' href='data:image/svg+xml,{quote(svg)}'>"
2923
3875
 
2924
3876
 
2925
- def render(theme: str | None = None) -> str:
3877
+ def render(theme: str | None = None, shown: str = "glance") -> str:
3878
+ with locked.snapshot(): # each file read once a render (metrics/locked.py)
3879
+ return _page(theme, shown)
3880
+
3881
+
3882
+ def _page(theme: str | None, shown: str = "glance") -> str:
2926
3883
  theme = theme if theme in themes() else saved_theme() # a ?theme preview, else the saved one
2927
3884
  return (
2928
3885
  f"<!doctype html><html lang='en' data-theme='{esc(theme)}'><meta charset='utf-8'>"
@@ -2936,7 +3893,7 @@ def render(theme: str | None = None) -> str:
2936
3893
  # The credit is fixed, not a setting (RELEASE-PLAN decision 7).
2937
3894
  "<p class='sub'>by <a href='https://wendellresearch.ai' target='_blank' rel='noopener'>Wendell Research</a></p>"
2938
3895
  "<div class='head-actions'>"
2939
- "<button class='btn fresh' id='fresh' hidden>New results · Reload</button>"
3896
+ "<button class='btn fresh' id='fresh' hidden>New results · Show</button>"
2940
3897
  # The onboarding queue's progress (bench.js fills it from /api/onboard-queue).
2941
3898
  "<div class='oq' id='oq' hidden role='status'>"
2942
3899
  "<span class='oq-chip' id='oq-chip'><span id='oq-text'></span></span>"
@@ -2946,7 +3903,7 @@ def render(theme: str | None = None) -> str:
2946
3903
  "<button class='btn primary' id='onboard-open'>Onboard a model</button></div></header>"
2947
3904
  # What needs a person first, then the pages; endpoints are managed from
2948
3905
  # Connections now (the header button moved there).
2949
- f"<main>{first_run()}<div class='layout'>{tabs()}</div></main>{editor()}"
3906
+ f"<main>{first_run()}<div class='layout'>{tabs(shown)}</div></main>{editor()}"
2950
3907
  f"<script>window.BENCH={directory_json()};</script>"
2951
3908
  # Observable Plot (on d3) draws the Benchmarks charts. Pinned versions from
2952
3909
  # jsDelivr; without a network the tables under each chart still work.
@@ -2959,9 +3916,8 @@ def gateway_card(limit: int = 30) -> str:
2959
3916
  """Recent bench-gateway requests: who sat, how each seat went, and which
2960
3917
  verdicts are still owed. A verdict owed is evidence the router is waiting
2961
3918
  for; this is where it shows."""
2962
- lines = read(DATA / "runs" / "gateway" / "calls.jsonl").splitlines()
2963
3919
  records = []
2964
- for line in reversed(lines):
3920
+ for line in _log_rows(DATA / "runs" / "gateway" / "calls.jsonl"): # newest first, from its end
2965
3921
  try:
2966
3922
  records.append(json.loads(line))
2967
3923
  except ValueError:
@@ -3034,7 +3990,7 @@ def gateway_card(limit: int = 30) -> str:
3034
3990
  how it was chosen (set on the collection, or a decision model's label).</dd>
3035
3991
  </dl>"""
3036
3992
  return card("Gateway", f"The last {len(records)} bench-gateway requests:"
3037
- f" {seats_total} seats, {owed} verdict{'s' * (owed != 1)} owed.",
3993
+ f" {seats_total} seats, <span id='g-owed' data-n='{owed}'>{owed} verdict{'s' * (owed != 1)} owed</span>.",
3038
3994
  "".join(body) + route_table(judged), key)
3039
3995
 
3040
3996
 
@@ -3082,9 +4038,10 @@ def route_table(judged: dict, limit: int = 30) -> str:
3082
4038
 
3083
4039
  def gateway_seat(call: str) -> dict:
3084
4040
  """One seat of a logged gateway request, with its latest verdict, for the
3085
- verdict dialog."""
4041
+ verdict dialog. Searched from the log's end, where a recent seat is: the
4042
+ log runs to 50 MB, read whole each time the dialog opened."""
3086
4043
  panel = call.rsplit(".", 1)[0]
3087
- for line in reversed(read(DATA / "runs" / "gateway" / "calls.jsonl").splitlines()):
4044
+ for line in _from_end(DATA / "runs" / "gateway" / "calls.jsonl"):
3088
4045
  if not panel or f'"{panel}"' not in line: # skip without parsing
3089
4046
  continue
3090
4047
  try:
@@ -3371,9 +4328,16 @@ def collections_tab() -> str:
3371
4328
  "</div>"
3372
4329
  "<p class='rules'>Explorers = min(max explorers, max calls − members): with 5 and 2, a"
3373
4330
  " 2- or 3-model collection gets 2, a 4-model one 1, a 5-model one none. These apply to"
3374
- " every collection that does not set its own in its dialog. A request that"
3375
- " names no collection is refused with each one's description and tags, so the caller"
3376
- " can choose.</p>"
4331
+ " every collection that does not set its own in its dialog.</p>"
4332
+ "<label class='check'><input type='checkbox' id='g-pick'> Choose a collection for a request that"
4333
+ " names none</label>"
4334
+ "<div class='settings'><div><label class='field' for='g-pick-conf'>Only when the model is at least"
4335
+ "</label><input id='g-pick-conf' type='number' min='0.05' max='1' step='0.05'></div></div>"
4336
+ "<p class='rules'>A decision model (the one Benchmarks › Decision models says the collection pick"
4337
+ " asks) chooses from the collections' descriptions and tags, and the reply says which and why"
4338
+ " (<code>bench.picked</code>, <code>X-Bench-Picked</code>). Less sure than this, or switched off,"
4339
+ " the request is refused with each collection's description and tags, so the caller can choose."
4340
+ " A name that is not a collection is always refused.</p>"
3377
4341
  "<div class='errors' id='g-errors' role='alert'></div><div class='done' id='g-done'></div>"
3378
4342
  "<div class='actions'>"
3379
4343
  "<button class='btn primary' id='g-save'>Save settings</button></div>"
@@ -3391,6 +4355,7 @@ SECTIONS = (("Observe", (("glance", "At a glance", "roster"),
3391
4355
  ("Gateway", (("endpoints", "Endpoints", "endpoints_page"),
3392
4356
  ("models", "Models", "models_page"),
3393
4357
  ("collections", "Collections", "collections_and_gateway"), # starred (bench.css)
4358
+ ("clients", "Clients", "clients_page"),
3394
4359
  ("discover", "Discover", "discover_card"))),
3395
4360
  ("Control", (("settings", "Settings", "settings_page"),
3396
4361
  ("info", "Info", "info_page"))))
@@ -3402,12 +4367,323 @@ def collections_and_gateway() -> str:
3402
4367
  return collections_tab() + gateway_card()
3403
4368
 
3404
4369
 
3405
- def tabs() -> str:
3406
- # Read once a render: Action needed's page, its count in the sidebar and
3407
- # At a glance's line about it.
3408
- groups, auto = open_actions()
3409
- count = {"needs": open_count(groups)}
3410
- page = {"needs": lambda: actions_page(groups, auto), "glance": lambda: actions_line(groups) + roster()}
4370
+ # ---- Clients: each app's set-up, filled in from this install ---------------
4371
+ # Each format follows the client's own docs as read on CLIENTS_READ (linked on
4372
+ # the page); only Claude Code was run against the gateway (GATEWAY.md, "Route
4373
+ # mode"). A key made on the page never goes into a file, which may sync: a file
4374
+ # names an environment variable, or the app asks, or (an MCP header in Zed,
4375
+ # Cline or goose, which take no variable) KEY_HOLDER stays for the reader.
4376
+ # Only blocks marked `key` (shell lines, fields typed into an app) get the key
4377
+ # made on the page, swapped in for KEY_HOLDER by bench.js.
4378
+ KEY_HOLDER = "<your gateway key>"
4379
+ KEY_ENV = "BENCHWARMERS_KEY" # the plugin's and the CLI's name for it too
4380
+ CLIENTS_READ = "2026-11-08"
4381
+ AGENT_OUT = 16_384 # the output budget an agent is told; route mode cuts it to the model's own
4382
+ UNKNOWN_CONTEXT = 128_000 # for a collection whose members' windows the catalog does not know
4383
+ DEFAULT_SERVER = "http://127.0.0.1:8122"
4384
+
4385
+
4386
+ def _snip(label: str, lang: str, text: str, key: bool = False) -> dict:
4387
+ return {"label": label, "lang": lang, "text": text, "key": key}
4388
+
4389
+
4390
+ def _env_snips(name: str) -> list[dict]:
4391
+ return [_snip("macOS, Linux: in your shell profile", "sh", f"export {name}='{KEY_HOLDER}'", True),
4392
+ _snip("Windows: once, then start the app again", "bat", f'setx {name} "{KEY_HOLDER}"', True)]
4393
+
4394
+
4395
+ def _fields(pairs: list) -> str:
4396
+ width = max(len(k) for k, _ in pairs)
4397
+ return "\n".join(f"{k.ljust(width)} {v}" for k, v in pairs)
4398
+
4399
+
4400
+ def client_setups(root: str, cols: list, keys: bool) -> list[dict]:
4401
+ """Every client's panel: what it gets, its snippets (_snip), where they
4402
+ go, the one thing to watch for and the docs they follow. `root`: the
4403
+ server, without /v1; `cols`: (name, context window or None, sees images)
4404
+ for each collection offered, the first being the one a single-model field
4405
+ names; `keys`: whether every call needs a gateway key."""
4406
+ base, mcp, env = root + "/v1", root + "/mcp", KEY_ENV
4407
+ fast = next((n for n, _, _ in cols if n == "fast"), cols[0][0])
4408
+ # (model, collection, context window, output budget, sees images)
4409
+ rows = [(f"bench/{n}:route", n, c or UNKNOWN_CONTEXT, min(AGENT_OUT, (c or UNKNOWN_CONTEXT) // 4), bool(eyes))
4410
+ for n, c, eyes in cols]
4411
+ model, ctx, out = rows[0][0], rows[0][2], rows[0][3]
4412
+ q = json.dumps # a quoted string, read the same by JSON, TOML and YAML
4413
+
4414
+ def js(data) -> str:
4415
+ return json.dumps(data, indent=2)
4416
+
4417
+ def lines(*parts) -> str:
4418
+ return "\n".join(p for p in parts if p is not None)
4419
+
4420
+ def when(text: str):
4421
+ return text if keys else None
4422
+
4423
+ opencode = {"$schema": "https://opencode.ai/config.json",
4424
+ "provider": {"benchwarmers": {
4425
+ "npm": "@ai-sdk/openai-compatible", "name": "Benchwarmers",
4426
+ "options": {"baseURL": base, **({"apiKey": f"{{env:{env}}}"} if keys else {})},
4427
+ "models": {m: {"name": f"{n} (route)", "limit": {"context": c, "output": o}}
4428
+ for m, n, c, o, _ in rows}}},
4429
+ "mcp": {"benchwarmers": {
4430
+ "type": "remote", "url": mcp, "enabled": True, "oauth": False,
4431
+ **({"headers": {"Authorization": f"Bearer {{env:{env}}}"}} if keys else {}),
4432
+ "timeout": 900_000}}}
4433
+ vscode = [{"name": "Benchwarmers", "vendor": "customendpoint", "apiKey": "${input:benchwarmersKey}",
4434
+ "apiType": "chat-completions",
4435
+ "models": [{"id": m, "name": f"{n} (Benchwarmers)", "url": base + "/chat/completions",
4436
+ "toolCalling": True, "vision": eyes, "maxInputTokens": c - o, "maxOutputTokens": o}
4437
+ for m, n, c, o, eyes in rows]}]
4438
+ vscode_mcp = {**({"inputs": [{"type": "promptString", "id": "benchwarmers-key",
4439
+ "description": "Benchwarmers gateway key", "password": True}]} if keys else {}),
4440
+ "servers": {"benchwarmers": {"type": "http", "url": mcp, **(
4441
+ {"headers": {"Authorization": "Bearer ${input:benchwarmers-key}"}} if keys else {})}}}
4442
+ codex = lines(f"model = {q(model)}", 'model_provider = "benchwarmers"', f"model_context_window = {ctx}",
4443
+ 'web_search = "disabled"', "",
4444
+ "[model_providers.benchwarmers]", 'name = "Benchwarmers"', f"base_url = {q(base)}",
4445
+ when(f'env_key = "{env}"'), 'wire_api = "responses"', "",
4446
+ "[mcp_servers.benchwarmers]", f"url = {q(mcp)}", when(f'bearer_token_env_var = "{env}"'),
4447
+ "tool_timeout_sec = 900")
4448
+ zed = {"language_models": {"openai_compatible": {"benchwarmers": {
4449
+ "api_url": base,
4450
+ "available_models": [{"name": m, "display_name": f"{n} (Benchwarmers)", "max_tokens": c,
4451
+ "max_output_tokens": o,
4452
+ "capabilities": {"tools": True, "images": eyes, "parallel_tool_calls": False,
4453
+ "prompt_cache_key": False}}
4454
+ for m, n, c, o, eyes in rows]}}},
4455
+ "context_servers": {"benchwarmers": {"url": mcp, **(
4456
+ {"headers": {"Authorization": f"Bearer {KEY_HOLDER}"}} if keys else {})}}}
4457
+ secret = f"${{{{ secrets.{env} }}}}"
4458
+ continue_ = lines("name: Benchwarmers", "version: 0.0.1", "schema: v1", "models:",
4459
+ *(lines(f" - name: {q(n + ' (Benchwarmers)')}", " provider: openai", f" model: {q(m)}",
4460
+ f" apiBase: {q(base)}", when(f" apiKey: {secret}"),
4461
+ " roles: [chat, edit, apply]",
4462
+ f" capabilities: [tool_use{', image_input' if eyes else ''}]",
4463
+ " defaultCompletionOptions:", f" contextLength: {c}", f" maxTokens: {o}")
4464
+ for m, n, c, o, eyes in rows),
4465
+ "mcpServers:", " - name: benchwarmers", " type: streamable-http", f" url: {q(mcp)}",
4466
+ when(" requestOptions:"), when(" headers:"),
4467
+ when(f" Authorization: Bearer {secret}"))
4468
+ cline_mcp = {"mcpServers": {"benchwarmers": {"type": "streamableHttp", "url": mcp, **(
4469
+ {"headers": {"Authorization": f"Bearer {KEY_HOLDER}"}} if keys else {}), "timeout": 900}}}
4470
+ librechat = lines("endpoints:", " custom:", ' - name: "Benchwarmers"',
4471
+ f' apiKey: "{"${" + env + "}" if keys else "local"}"', f" baseURL: {q(base)}",
4472
+ " models:", f" default: [{', '.join(q(f'bench/{n}:merged') for n, _, _ in cols)}]",
4473
+ " fetch: true", " titleConvo: false", ' modelDisplayLabel: "Benchwarmers"')
4474
+ goose = {"name": "benchwarmers", "engine": "openai", "display_name": "Benchwarmers",
4475
+ "description": "Benchwarmers collections, one model per conversation (route mode)",
4476
+ "api_key_env": env, "base_url": base + "/chat/completions",
4477
+ "models": [{"name": m, "context_limit": c} for m, _, c, _, _ in rows],
4478
+ "supports_streaming": True, "requires_auth": keys}
4479
+ goose_ext = lines("extensions:", " benchwarmers:", " type: streamable_http", " name: benchwarmers",
4480
+ " enabled: true", f" uri: {q(mcp)}",
4481
+ f" headers: {{Authorization: {q('Bearer ' + KEY_HOLDER)}}}" if keys else " headers: {}",
4482
+ " timeout: 900")
4483
+ plugin_at = ("" if root == DEFAULT_SERVER else f", so set it to <code>{esc(root)}</code>")
4484
+ any_text = "" if keys else " Keys are not asked for here, so any text works."
4485
+ return [
4486
+ {"id": "opencode", "name": "opencode",
4487
+ "gets": "An agent with its tools on any collection (route mode), and the panel's ask and verdict as MCP tools.",
4488
+ "where": "<code>~/.config/opencode/opencode.json</code>, or <code>opencode.json</code> in a project; merge"
4489
+ " into what is there. Pick a model with <code>/models</code>"
4490
+ f" (<code>benchwarmers/{esc(model)}</code>)."
4491
+ + (f" The key comes from <code>{env}</code>." if keys else ""),
4492
+ "blocks": [_snip("opencode.json", "json", js(opencode))],
4493
+ "watch": "<code>timeout</code> is in milliseconds and covers each MCP call: without it opencode stops"
4494
+ " waiting long before a panel answers.",
4495
+ "docs": [("https://github.com/anomalyco/opencode/blob/v1.19.5/packages/web/src/content/docs/providers.mdx",
4496
+ "v1.19.5's providers"),
4497
+ ("https://github.com/anomalyco/opencode/blob/v1.19.5/packages/web/src/content/docs/mcp-servers.mdx",
4498
+ "MCP servers")],
4499
+ "docs_note": "the current release's own docs. opencode.ai already shows the next config"
4500
+ " (<code>providers</code>, <code>mcp.servers</code>), which v1.19.5 does not read."},
4501
+ {"id": "vscode", "name": "VS Code chat (Custom Endpoint)",
4502
+ "gets": "Copilot chat's agent on a collection (route mode), with your own key, and the panel as MCP tools.",
4503
+ "where": "<code>chatLanguageModels.json</code>: in the chat's model picker, Manage Models, Add Models,"
4504
+ " Custom Endpoint; add the object to the list there. <code>mcp.json</code>: the command"
4505
+ " <b>MCP: Open User Configuration</b>. VS Code asks for the key once and keeps it." + any_text,
4506
+ "blocks": [_snip("chatLanguageModels.json", "json", js(vscode)), _snip("mcp.json", "json", js(vscode_mcp))],
4507
+ "watch": "Keep <code>toolCalling: true</code>: VS Code hides a model without it from the agent picker."
4508
+ " Restart VS Code if the models do not appear.",
4509
+ "docs": [("https://code.visualstudio.com/docs/copilot/customization/language-models", "language models"),
4510
+ ("https://code.visualstudio.com/docs/agents/reference/mcp-configuration", "MCP configuration")]},
4511
+ {"id": "codex", "name": "Codex CLI",
4512
+ "gets": "Codex on a collection over the Responses API (route mode), and the panel as MCP tools.",
4513
+ "where": "<code>~/.codex/config.toml</code> (<code>%USERPROFILE%\\.codex\\config.toml</code> on Windows)."
4514
+ " The first four lines go above the file's first <code>[table]</code>."
4515
+ + (f" The key comes from <code>{env}</code>." if keys else ""),
4516
+ "blocks": [_snip("config.toml", "toml", codex)],
4517
+ "watch": "<code>web_search = \"disabled\"</code>: left on, Codex sends OpenAI's hosted search, which the"
4518
+ " gateway refuses (no other model can run it), and every turn fails.",
4519
+ "docs": [("https://learn.chatgpt.com/docs/config-file/config-reference", "configuration reference")]},
4520
+ {"id": "zed", "name": "Zed",
4521
+ "gets": "Zed's agent on a collection (route mode), and the panel as MCP tools.",
4522
+ "where": "<code>settings.json</code>: the command <b>zed: open settings file</b>. Zed reads the key from"
4523
+ " <code>BENCHWARMERS_API_KEY</code>, named after the provider id, or from the provider's settings"
4524
+ " screen; never from settings.json." + any_text,
4525
+ "blocks": [_snip("settings.json", "json", js(zed)), *_env_snips("BENCHWARMERS_API_KEY")],
4526
+ "watch": ("settings.json takes no variable, so the MCP header holds the key as text: replace"
4527
+ f" <code>{esc(KEY_HOLDER)}</code> there yourself, and keep the file out of synced dotfiles."
4528
+ if keys else "Zed asks for a key even where none is needed: set the variable to any text."),
4529
+ "docs": [("https://zed.dev/docs/ai/use-api-access", "API access"), ("https://zed.dev/docs/ai/mcp", "MCP")]},
4530
+ {"id": "continue", "name": "Continue",
4531
+ "gets": "Continue's chat, edit and agent on a collection (route mode), and the panel as MCP tools.",
4532
+ "where": "<code>~/.continue/config.yaml</code> (<code>%USERPROFILE%\\.continue\\config.yaml</code> on"
4533
+ " Windows); in a file you have, add the items to its <code>models</code> and"
4534
+ " <code>mcpServers</code>."
4535
+ + (f" <code>secrets.{env}</code> is read from <code>~/.continue/.env</code> or the environment."
4536
+ if keys else ""),
4537
+ "blocks": [_snip("config.yaml", "yaml", continue_)],
4538
+ "watch": "Keep <code>capabilities: [tool_use]</code>: without it Continue offers the model for chat,"
4539
+ " not agent mode.",
4540
+ "docs": [("https://docs.continue.dev/customize/model-providers/top-level/openai", "OpenAI provider"),
4541
+ ("https://docs.continue.dev/customize/deep-dives/mcp", "MCP"),
4542
+ ("https://docs.continue.dev/reference", "config.yaml reference")]},
4543
+ {"id": "cline", "name": "Cline and Kilo Code",
4544
+ "gets": "Their agent on a collection (route mode), set in their own screens; Cline takes the panel as"
4545
+ " MCP tools too.",
4546
+ "where": "Cline: its settings, API Provider. Kilo Code: Settings, Providers, Custom provider. Cline's MCP:"
4547
+ " the MCP Servers icon, Configure, Configure MCP Servers." + any_text,
4548
+ "blocks": [_snip("Cline", "text", _fields([("API Provider", "OpenAI Compatible"), ("Base URL", base),
4549
+ ("API Key", KEY_HOLDER), ("Model ID", model),
4550
+ ("Context Window Size", ctx), ("Max Output Tokens", out),
4551
+ ("Image Support", "on" if rows[0][4] else "off")]), True),
4552
+ _snip("Kilo Code", "text", _fields([("Provider ID", "benchwarmers"),
4553
+ ("Display name", "Benchwarmers"),
4554
+ ("Provider API", "OpenAI Compatible"), ("Base URL", base),
4555
+ ("API key", KEY_HOLDER if keys else "(leave empty)"),
4556
+ ("Models", ", ".join(m for m, *_ in rows))]), True),
4557
+ _snip("Cline: cline_mcp_settings.json", "json", js(cline_mcp))],
4558
+ "watch": "Keep <code>\"type\": \"streamableHttp\"</code>: without it Cline tries the old SSE transport."
4559
+ " Its <code>timeout</code> is in seconds (60 unless set).",
4560
+ "docs": [("https://docs.cline.bot/provider-config/openai-compatible", "Cline: OpenAI Compatible"),
4561
+ ("https://docs.cline.bot/mcp/configuring-mcp-servers", "Cline: MCP servers"),
4562
+ ("https://kilo.ai/docs/providers/openai-compatible", "Kilo Code: OpenAI Compatible")]},
4563
+ {"id": "claude-code", "name": "Claude Code",
4564
+ "gets": "The panel as MCP tools, with the plugin's <code>/benchwarmers:ask</code>, its skill and its"
4565
+ " reminder of seats still unjudged; or Claude Code itself on a collection (route mode).",
4566
+ "where": f"The plugin finds the server at <code>BENCHWARMERS_URL</code>, <code>{DEFAULT_SERVER}</code>"
4567
+ f" unless set{plugin_at}" + (f", and sends <code>{env}</code>." if keys else ".")
4568
+ + " Route mode: the shell you start <code>claude</code> from." + any_text,
4569
+ "blocks": [_snip("In Claude Code: the plugin", "text",
4570
+ "/plugin marketplace add WendellResearch-AI/benchwarmers\n"
4571
+ "/plugin install benchwarmers@benchwarmers"),
4572
+ _snip("Claude Code on a collection: a shell", "sh",
4573
+ lines(f"export ANTHROPIC_BASE_URL={root}", f"export ANTHROPIC_API_KEY='{KEY_HOLDER}'",
4574
+ f"export ANTHROPIC_MODEL={model}",
4575
+ f"export ANTHROPIC_DEFAULT_HAIKU_MODEL=bench/{fast}:route", "claude"), True)],
4576
+ "watch": "The plugin's Stop hook needs the server on the same machine; with none running, every turn"
4577
+ " ends with a hook error.",
4578
+ "docs": [("https://github.com/WendellResearch-AI/benchwarmers#from-claude-code-and-other-agents",
4579
+ "README, From Claude Code and other agents")],
4580
+ "docs_note": "checked end to end with Claude Code 2.1.299 on 2026-10-23 (GATEWAY.md, Route mode)."},
4581
+ {"id": "chat", "name": "Chat apps",
4582
+ "gets": "LobeHub, AnythingLLM, Cherry Studio, Chatbox and LibreChat: a chat window on a collection, one"
4583
+ " merged answer per question, every seat still called and recorded.",
4584
+ "where": "Each app's settings, as an OpenAI-compatible (custom) provider. LibreChat:"
4585
+ " <code>librechat.yaml</code>"
4586
+ + (f", with <code>{env}</code> in its <code>.env</code>." if keys else ".") + any_text,
4587
+ "blocks": [_snip("Any of them", "text", _fields([("Base URL", base), ("API key", KEY_HOLDER),
4588
+ ("Model", f"bench/{cols[0][0]}:merged")]), True),
4589
+ _snip("LibreChat: librechat.yaml", "yaml", librechat)],
4590
+ "watch": "An app in Docker reaches this machine at another address than 127.0.0.1, and that host:port"
4591
+ " goes in <code>BENCH_ALLOWED_HOSTS</code>. Turn automatic titles off: each runs a whole panel.",
4592
+ "docs": [("https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/custom_endpoint",
4593
+ "LibreChat: custom endpoints")]},
4594
+ {"id": "goose", "name": "goose",
4595
+ "gets": "goose on a collection (route mode), and the panel as an MCP extension.",
4596
+ "where": "The provider: <code>~/.config/goose/custom_providers/benchwarmers.json</code>. The extension:"
4597
+ " <code>~/.config/goose/config.yaml</code>. On Windows both are under"
4598
+ " <code>%APPDATA%\\Block\\goose\\config\\</code>."
4599
+ + (f" goose reads the provider's key from <code>{env}</code>, never from config.yaml." if keys
4600
+ else ""),
4601
+ "blocks": [_snip("custom_providers/benchwarmers.json", "json", js(goose)),
4602
+ _snip("config.yaml", "yaml", goose_ext)],
4603
+ "watch": ("The extension's header holds the key as text: replace"
4604
+ f" <code>{esc(KEY_HOLDER)}</code> there yourself." if keys else
4605
+ "<code>timeout</code> is in seconds; goose's usual 300 can end a long panel."),
4606
+ "docs": [("https://goose-docs.ai/docs/getting-started/providers", "providers"),
4607
+ ("https://goose-docs.ai/docs/getting-started/using-extensions", "extensions"),
4608
+ ("https://goose-docs.ai/docs/guides/config-files", "config files")]},
4609
+ ]
4610
+
4611
+
4612
+ def clients_page() -> str:
4613
+ """Clients: a panel per app (client_setups), from this install's address,
4614
+ collections and whether keys are asked for. A key made here is shown once
4615
+ and goes only into this page's blocks marked data-key (bench.js)."""
4616
+ root = gateway_url().removesuffix("/v1")
4617
+ offered = sorted((n, c) for n, c in bench_collections.load().items() if not c.get("ad_hoc"))
4618
+ offered.sort(key=lambda nc: nc[0] != "core") # core first, where there is one
4619
+ cols = []
4620
+ for n, c in offered:
4621
+ together = bench_collections.described(n, c).get("together") or {}
4622
+ cols.append((n, together.get("context_window"), "vision" in (together.get("capabilities") or [])))
4623
+ keys = guard.keys_required()
4624
+ head = ("<div class='panel form-panel'><h2>Clients</h2><p class='sub'>A ready set-up for each app, filled in from"
4625
+ " this install: copy it into the file or screen named. Agents get a collection in route mode (one"
4626
+ " member with its tools, <code>bench/&lt;collection&gt;:route</code>); chat windows a merged answer"
4627
+ " (<code>:merged</code>); the MCP server the panel itself, as ask and verdict tools.</p>"
4628
+ f"<p class='where'>Gateway <code>{esc(root)}/v1</code> · MCP server <code>{esc(root)}/mcp</code><br>"
4629
+ f"Collections: {esc(', '.join(n for n, _ in offered)) or 'none yet'}"
4630
+ "<span class='muted'> (ad hoc ones are left out: name one yourself to use it)</span></p>")
4631
+ if not cols:
4632
+ return head + ("<p>No collection yet: make one on <button class='btn' type='button' data-go='collections'>"
4633
+ "Collections</button>, and the set-ups appear here.</p></div>")
4634
+ if keys:
4635
+ head += ("<h3 class='form-head'>Gateway key</h3><p class='rules'>Every call needs one. Make one for these"
4636
+ f" apps and it fills in every <code>{esc(KEY_HOLDER)}</code> below that is not in a file, on this"
4637
+ " page only: it is shown this once and kept as a hash. Or use one you have.</p>"
4638
+ "<label class='field' for='cl-name'>Name for a new key</label>"
4639
+ "<input id='cl-name' placeholder='e.g. opencode' autocomplete='off'>"
4640
+ "<div class='errors' id='cl-errors' role='alert'></div>"
4641
+ "<div class='done' id='cl-done' hidden>Made: copy what you need now, as it is gone when this page"
4642
+ " reloads. Settings lists it.</div>"
4643
+ "<div class='actions'><button class='btn primary' type='button' id='cl-make'>Make a key</button></div>"
4644
+ f"<p class='rules'>Most snippets read it from <code>{KEY_ENV}</code>:</p>"
4645
+ + "".join(_snip_html("key", i, s) for i, s in enumerate(_env_snips(KEY_ENV))))
4646
+ else:
4647
+ head += ("<p class='rules'>Keys are not asked for on this machine, so the snippets carry none where an"
4648
+ f" app allows it; where one wants a key, any text works in place of <code>{esc(KEY_HOLDER)}</code>."
4649
+ " Set <code>BENCHWARMERS_REQUIRE_KEYS=1</code> (or an admin token) and this page offers to make"
4650
+ " one.</p>")
4651
+ panels = "".join(
4652
+ f"<div class='panel client' id='client-{c['id']}'><h2>{esc(c['name'])}</h2>"
4653
+ f"<p class='sub'>{c['gets']}</p><p class='where'>{c['where']}</p>"
4654
+ + "".join(_snip_html(c["id"], i, s) for i, s in enumerate(c["blocks"]))
4655
+ + f"<p class='watch'><b>Watch for:</b> {c['watch']}</p>"
4656
+ + "<p class='rules'>Written from " + ", ".join(ext_link(u, t) for u, t in c["docs"])
4657
+ + f", read {CLIENTS_READ}" + (f": {c['docs_note']}" if c.get("docs_note") else ".") + "</p></div>"
4658
+ for c in client_setups(root, cols, keys))
4659
+ return head + "</div>" + panels
4660
+
4661
+
4662
+ def _snip_html(group: str, i: int, s: dict) -> str:
4663
+ pid = f"cl-{group}-{i}"
4664
+ return (f"<div class='snip'><div class='snip-head'><span class='mono'>{esc(s['label'])}</span>"
4665
+ f"<button class='btn small' type='button' data-copy='{pid}'>Copy</button></div>"
4666
+ f"<pre class='log' id='{pid}' data-lang='{s['lang']}'{' data-key' if s['key'] else ''}>"
4667
+ f"{esc(s['text'])}</pre></div>")
4668
+
4669
+
4670
+ def pane(key: str, actions: tuple | None = None) -> str:
4671
+ """One tab's page, as its section holds it. The page renders the tab shown
4672
+ (tabs); bench.js fetches the others from GET /api/pane when first opened."""
4673
+ if key in ("needs", "glance"):
4674
+ groups, auto = actions or open_actions()
4675
+ return actions_page(groups, auto) if key == "needs" else actions_line(groups) + roster()
4676
+ return globals()[next(fn for k, _, fn in TABS if k == key)]()
4677
+
4678
+
4679
+ def tabs(shown: str = "glance") -> str:
4680
+ # Read once a render: Action needed's count in the sidebar, and its page or
4681
+ # At a glance's line about it when one of those is shown. Only the tab
4682
+ # shown is rendered (the bench_tab cookie, set by bench.js): rendering every
4683
+ # one took seconds. The rest are empty until opened (data-lazy).
4684
+ actions = open_actions()
4685
+ count = {"needs": open_count(actions[0])}
4686
+ shown = shown if any(k == shown for k, _, _ in TABS) else "glance"
3411
4687
 
3412
4688
  def button(key: str, label: str) -> str:
3413
4689
  n = count.get(key)
@@ -3417,8 +4693,8 @@ def tabs() -> str:
3417
4693
  nav = "".join(f"<div class='group'><h2>{esc(group)}</h2>"
3418
4694
  + "".join(button(key, label) for key, label, _ in pages) + "</div>"
3419
4695
  for group, pages in SECTIONS)
3420
- panes = "".join(f"<section class='tab' id='tab-{key}'>{page[key]() if key in page else globals()[fn]()}</section>"
3421
- for key, _, fn in TABS)
4696
+ panes = "".join(f"<section class='tab' id='tab-{key}'>{pane(key, actions)}</section>" if key == shown
4697
+ else f"<section class='tab' id='tab-{key}' data-lazy></section>" for key, _, _ in TABS)
3422
4698
  return f"<nav class='tabs' role='tablist' aria-label='Pages'>{nav}</nav><div class='panes'>{panes}</div>"
3423
4699
 
3424
4700
 
@@ -3528,6 +4804,149 @@ def login_page() -> str:
3528
4804
  "});</script>")
3529
4805
 
3530
4806
 
4807
+ # ---- Export and import: this install's settings, or everything, as one zip --
4808
+ # Moving an install is a copy of its data folder (metrics/paths.py); this is that
4809
+ # copy as a download and an upload. The keys are not in it: they are in the llm
4810
+ # key store, and only the endpoints' own travel, only when asked.
4811
+ MOVE_SCOPES = {"settings": ("config",), "all": ("config", "metrics", "perf", "runs", "suite")}
4812
+ IMPORT_MAX = 2_000_000_000 # bytes; everything on the owner's install is about 90 MB
4813
+ _KEY_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
4814
+
4815
+
4816
+ def _endpoint_keys() -> dict:
4817
+ """The stored keys the endpoints use: each one's key, admin key and refresh
4818
+ token. Never one from the environment, nor the llm CLI's own others."""
4819
+ names = set()
4820
+ for entry in endpoints.load().values():
4821
+ key = entry.get("key", "")
4822
+ names |= {key, endpoints.admin_name(entry), f"{key}.refresh"}
4823
+ return {n: v for n, v in endpoints._stored().items() if n in names and isinstance(v, str) and v}
4824
+
4825
+
4826
+ def export_zip(scope: str, keys: bool, out) -> dict:
4827
+ """Write an export to the binary file `out`: the scope's folders (lock files
4828
+ never), keys.json when `keys`, and manifest.json. Returns the manifest."""
4829
+ import zipfile
4830
+ if scope not in MOVE_SCOPES:
4831
+ raise ValueError(f"scope is one of {', '.join(MOVE_SCOPES)}")
4832
+ made = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
4833
+ manifest = {"benchwarmers_export": 1, "release": RELEASE, "made": made, "scope": scope,
4834
+ "files": 0, "keys": 0, "skipped": []}
4835
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
4836
+ for top in MOVE_SCOPES[scope]:
4837
+ for path in sorted((DATA / top).rglob("*")):
4838
+ if not path.is_file() or path.suffix == ".lock":
4839
+ continue
4840
+ name = path.relative_to(DATA).as_posix()
4841
+ try:
4842
+ z.write(path, name)
4843
+ manifest["files"] += 1
4844
+ except OSError as exc: # held open by another process
4845
+ manifest["skipped"].append(f"{name}: {exc.strerror or exc}")
4846
+ if keys:
4847
+ have = _endpoint_keys()
4848
+ z.writestr("keys.json", json.dumps(have, indent=2))
4849
+ manifest["keys"] = len(have)
4850
+ z.writestr("manifest.json", json.dumps(manifest, indent=2))
4851
+ return manifest
4852
+
4853
+
4854
+ def _movable(name: str, tops: tuple) -> bool:
4855
+ """A name an import may write: relative, inside one of the scope's folders,
4856
+ no way out of the data folder, not a lock file."""
4857
+ parts = name.split("/")
4858
+ return (parts[0] in tops and len(parts) > 1 and "\\" not in name and ":" not in name
4859
+ and not any(p in ("", ".", "..") for p in parts) and not name.endswith(".lock"))
4860
+
4861
+
4862
+ def import_zip(src, apply: bool) -> dict:
4863
+ """Read an export (`src`, a binary file) and say what is in it. With
4864
+ `apply`: this install's files in its scope go to backups/ first (an export
4865
+ of them), then its files replace this install's of the same name and its
4866
+ keys go to the key store. Files this install has and it does not are left."""
4867
+ import shutil
4868
+ import zipfile
4869
+
4870
+ def refused(why: str) -> dict:
4871
+ return {"ok": False, "errors": [why]}
4872
+ try:
4873
+ z = zipfile.ZipFile(src)
4874
+ except zipfile.BadZipFile:
4875
+ return refused("that is not a zip file")
4876
+ with z:
4877
+ try:
4878
+ m = json.loads(z.read("manifest.json"))
4879
+ except (KeyError, ValueError):
4880
+ return refused("it has no manifest.json: not a Benchwarmers export")
4881
+ if not isinstance(m, dict) or m.get("benchwarmers_export") != 1 or m.get("scope") not in MOVE_SCOPES:
4882
+ return refused("its manifest is not one this version reads")
4883
+ tops = MOVE_SCOPES[m["scope"]]
4884
+ files = [i for i in z.infolist() if not i.is_dir() and i.filename not in ("manifest.json", "keys.json")]
4885
+ bad = [i.filename for i in files if not _movable(i.filename, tops)]
4886
+ if bad:
4887
+ return refused(f"{len(bad)} file(s) in it are outside {', '.join(tops)}, e.g. {bad[0]!r}: nothing imported")
4888
+ try:
4889
+ keys = json.loads(z.read("keys.json")) if "keys.json" in z.namelist() else {}
4890
+ except ValueError:
4891
+ return refused("its keys.json is not JSON")
4892
+ if not (isinstance(keys, dict) and all(isinstance(v, str) and _KEY_NAME.match(k) for k, v in keys.items())):
4893
+ return refused("its keys.json is not a list of key names and keys")
4894
+ out = {"ok": True, "errors": [], "scope": m["scope"], "release": str(m.get("release", "")),
4895
+ "made": str(m.get("made", "")), "files": len(files), "bytes": sum(i.file_size for i in files),
4896
+ "replaces": sum((DATA / i.filename).is_file() for i in files), "keys": len(keys)}
4897
+ if out["bytes"] > 10 * IMPORT_MAX: # zipfile stops each file at its stated size
4898
+ return refused(f"it unpacks to {out['bytes']:,} bytes: more than any install holds")
4899
+ if not apply:
4900
+ return out
4901
+ stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
4902
+ backup = DATA / "backups" / f"before-import-{stamp}.zip"
4903
+ backup.parent.mkdir(parents=True, exist_ok=True)
4904
+ with open(backup, "wb") as f:
4905
+ export_zip(m["scope"], bool(keys), f)
4906
+ out["backup"] = backup.relative_to(DATA).as_posix()
4907
+ done = 0
4908
+ try:
4909
+ for i in files:
4910
+ dest = DATA / i.filename
4911
+ dest.parent.mkdir(parents=True, exist_ok=True)
4912
+ part = dest.with_name(f".{dest.name}.{os.getpid()}.part")
4913
+ try:
4914
+ with z.open(i) as r, open(part, "wb") as w:
4915
+ shutil.copyfileobj(r, w)
4916
+ os.replace(part, dest)
4917
+ finally:
4918
+ part.unlink(missing_ok=True)
4919
+ done += 1
4920
+ except (OSError, zipfile.BadZipFile) as exc: # a full disk, a file held open, a damaged zip
4921
+ return refused(f"stopped after {done} of {len(files)} files ({exc}); what it replaced is in"
4922
+ f" {out['backup']}")
4923
+ for name, value in keys.items():
4924
+ endpoints.set_key(name, value)
4925
+ return out
4926
+
4927
+
4928
+ def move_card() -> str:
4929
+ """Settings' Export and import (export_zip, import_zip; bench.js)."""
4930
+ return ("<div class='panel form-panel'><h2>Export and import</h2><p class='muted'>Take this install to another"
4931
+ " machine, or keep a copy of it: one .zip file, imported on the other install's Settings page.</p>"
4932
+ "<div class='pick'><label class='check'><input type='radio' name='x-scope' value='settings' checked"
4933
+ " data-kept> <span><b>Settings only</b>: endpoints, collections, roles, model options, the schedule,"
4934
+ " gateway keys, the theme (<code>config/</code>)</span></label>"
4935
+ "<label class='check'><input type='radio' name='x-scope' value='all' data-kept> <span><b>Everything</b>:"
4936
+ " the settings, and every result, history, log and saved answer the bench judges by</span></label></div>"
4937
+ "<label class='check'><input type='checkbox' id='x-keys' data-kept> <span>With the endpoints' API keys."
4938
+ " The file then holds them: keep it like a password, and delete it once imported.</span></label>"
4939
+ "<div class='actions'><button class='btn' type='button' id='x-export'>Download export</button></div>"
4940
+ "<h3 class='form-head'>Import</h3>"
4941
+ "<p class='rules'>Its files replace this install's of the same name; files it does not have are left."
4942
+ " This install's own go to <code>backups/</code> first. Restart afterwards (Server and access, below),"
4943
+ " so every part reads them.</p>"
4944
+ "<input type='file' id='x-file' accept='.zip,application/zip'>"
4945
+ "<div class='done' id='x-what' role='status'></div><div class='errors' id='x-errors' role='alert'></div>"
4946
+ "<div class='actions'><button class='btn primary' type='button' id='x-import' disabled>Import</button>"
4947
+ "</div></div>")
4948
+
4949
+
3531
4950
  def access_card() -> str:
3532
4951
  """How this install is reached and who may call it, from the environment:
3533
4952
  shown here, never set here. Each needs a restart, and a page that could
@@ -3664,7 +5083,9 @@ def save_client(name: str, who: str = "ui") -> dict:
3664
5083
  locked.update(model_client.CHOICE, lambda d: d.update(client=name))
3665
5084
  _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "",
3666
5085
  "model client", old, name)
3667
- return {"ok": True, "errors": []}
5086
+ # Not kept in step while it was not the client (_sync_cli): brought up to
5087
+ # date now, and on every save of it, so saving it again retries a failed sync.
5088
+ return {"ok": True, "errors": [], "sync": _sync_cli() if name == "llm" else ""}
3668
5089
 
3669
5090
 
3670
5091
  def client_card() -> str:
@@ -3774,7 +5195,7 @@ def settings_page() -> str:
3774
5195
  " (cron, Windows Task Scheduler), turn it off in the same step, or every job runs twice.</p>"
3775
5196
  "<div class='errors' id='s-errors' role='alert'></div><div class='done' id='s-done'></div>"
3776
5197
  "<div class='actions'><button class='btn primary' id='s-save'>Save scheduler</button></div></div>"
3777
- + client_card() + own_client_card() + access_card())
5198
+ + client_card() + own_client_card() + move_card() + access_card())
3778
5199
 
3779
5200
 
3780
5201
  def endpoints_page() -> str:
@@ -3849,6 +5270,8 @@ def endpoints_page() -> str:
3849
5270
  f" check listed{' on ' + esc(checked) if checked else ''}.</p>"
3850
5271
  "<div class='list-actions'><button class='btn primary' id='e-add'>Add an endpoint</button>"
3851
5272
  "<span class='done' id='el-done'></span><span class='errors' id='el-errors' role='alert'></span></div>"
5273
+ # Model servers running here, not endpoints yet: bench.js asks /api/connect/local on show.
5274
+ "<div class='found' id='e-found' hidden><h3>Found on this machine</h3><div id='e-found-list'></div></div>"
3852
5275
  "<div class='toolbar'><input type='search' data-filter placeholder='Find a model on any endpoint'"
3853
5276
  " aria-label='Filter models'></div>"
3854
5277
  f"{''.join(blocks)}</div>"
@@ -4022,8 +5445,32 @@ def quotas_page() -> str:
4022
5445
  f"<script type='application/json' id='quota-data'>{data}</script>")
4023
5446
 
4024
5447
 
5448
+ def _from_end(path: pathlib.Path):
5449
+ """A log's lines that hold something, newest first, read from its end in
5450
+ growing chunks, so a caller that stops early never reads the rest: the
5451
+ gateway's log runs to 50 MB. Nothing when it cannot be read."""
5452
+ try:
5453
+ fh = open(path, "rb")
5454
+ except OSError:
5455
+ return
5456
+ with fh:
5457
+ end, back, cut = fh.seek(0, os.SEEK_END), 1 << 18, b""
5458
+ while end:
5459
+ start = max(end - back, 0)
5460
+ fh.seek(start)
5461
+ lines = (fh.read(end - start) + cut).split(b"\n")
5462
+ cut = lines.pop(0) if start else b"" # may be cut: finished by the chunk before it
5463
+ for line in reversed(lines):
5464
+ if line.strip():
5465
+ yield line.decode("utf-8", "replace").rstrip("\r")
5466
+ end, back = start, back * 4
5467
+
5468
+
4025
5469
  def _log_rows(path: pathlib.Path, limit: int = 200) -> list[str]:
4026
- return [line for line in reversed(read(path).splitlines()) if line.strip()][:limit]
5470
+ """The last `limit` lines that hold something, newest first. Read from the
5471
+ end: the gateway's log runs to 50 MB, and was read whole for 200 lines."""
5472
+ from itertools import islice
5473
+ return list(islice(_from_end(path), limit))
4027
5474
 
4028
5475
 
4029
5476
  def logs_page() -> str:
@@ -4102,13 +5549,17 @@ def on_disk() -> tuple[str, str]:
4102
5549
  def info_page() -> str:
4103
5550
  import scheduler
4104
5551
  import client as model_client
4105
- rows = [("Version", RELEASE), ("Date", RELEASE_DATE or "unknown"), ("Commit", COMMIT or "unknown"),
4106
- ("Python", f"{sys.version.split()[0]} · {sys.executable}"),
4107
- ("Folder", str(ROOT)), ("Data folder", str(DATA)), ("Endpoints", str(len(endpoints.load()))),
4108
- ("Models on the bench", str(len(_chat_aliases()))),
4109
- ("Scheduler", scheduler.settings()["mode"]), ("Model client", model_client.chosen()[0]),
4110
- ("Theme", saved_theme())]
4111
- body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td class='mono'>{esc(v)}</td></tr>" for k, v in rows)
5552
+ # The release this is, or on a branch the one it builds on (0.5.6-dev -> v0.5.6).
5553
+ rows = [("Source", ext_link(connect.REPO, connect.REPO.removeprefix("https://"))),
5554
+ ("Version", ext_link(f"{connect.REPO}/releases/tag/v{RELEASE.split('-')[0]}", RELEASE))]
5555
+ rows += [(k, esc(v)) for k, v in [
5556
+ ("Date", RELEASE_DATE or "unknown"), ("Commit", COMMIT or "unknown"),
5557
+ ("Python", f"{sys.version.split()[0]} · {sys.executable}"),
5558
+ ("Folder", str(ROOT)), ("Data folder", str(DATA)), ("Endpoints", str(len(endpoints.load()))),
5559
+ ("Models on the bench", str(len(_chat_aliases()))),
5560
+ ("Scheduler", scheduler.settings()["mode"]), ("Model client", model_client.chosen()[0]),
5561
+ ("Theme", saved_theme())]]
5562
+ body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td class='mono'>{v}</td></tr>" for k, v in rows)
4112
5563
  return (f"<div class='panel'><h2>Info</h2><table class='facts'><tbody>{body}</tbody></table>"
4113
5564
  "<p class='muted'>Benchwarmers · by Wendell Research · MIT licence"
4114
5565
  " (<code>LICENSE</code>, credits in <code>NOTICE</code>). Every response carries"
@@ -4119,8 +5570,11 @@ def directory_json() -> str:
4119
5570
  directory = model_directory()
4120
5571
  facts = {a: catalog.info(a) for a, d in directory.items() if d.get("kind") != "decision"}
4121
5572
  options = {a: o for a, o in load_json(OPTIONS_PATH).items() if a in facts and isinstance(o, dict)}
5573
+ # A decision model's role suggestion from onboarding (decide.suggest_role): shown, never applied.
5574
+ suggested = {a: s for a, s in (locked.read_json(decide.DECIDED).get("roles") or {}).items() if isinstance(s, dict)}
4122
5575
  return json.dumps({"models": directory, "facts": facts, "options": options, "roles": model_roles.load()["roles"],
4123
- "retired": retired(), "onboard": onboard_issues(), "stamp": data_stamp()},
5576
+ "suggested": suggested, "retired": retired(), "onboard": onboard_issues(), "stamp": data_stamp(),
5577
+ "repo": connect.REPO},
4124
5578
  ensure_ascii=False).replace("</", "<\\/")
4125
5579
 
4126
5580
 
@@ -4139,6 +5593,7 @@ def editor() -> str:
4139
5593
  " history and run notes keep following this model.</p>"
4140
5594
  "<label class='field' for='m-role'>Role</label><select id='m-role'></select>"
4141
5595
  "<p class='rules' id='m-role-why'></p>"
5596
+ "<p class='note' id='m-role-hint' hidden></p>"
4142
5597
  "<fieldset id='m-qtypes' hidden><legend class='field'>Question types</legend>"
4143
5598
  "<label class='check'><input type='checkbox' value='noul'> yes/no"
4144
5599
  " <span class='muted'>(noul: a probability that the answer is yes)</span></label>"
@@ -4355,10 +5810,10 @@ class Handler(gateway.Handler):
4355
5810
  callback = self.path.startswith((connect.CALLBACK + "?", connect.CALLBACK + "/"))
4356
5811
  if not callback and not self._admitted(path):
4357
5812
  return
5813
+ from urllib.parse import parse_qs, urlparse
5814
+ q = parse_qs(urlparse(self.path).query)
4358
5815
  if self.path.startswith("/api/onboard?"):
4359
- from urllib.parse import parse_qs, urlparse
4360
- log = parse_qs(urlparse(self.path).query).get("log", [""])[0]
4361
- self._json(200, onboard_status(log))
5816
+ self._json(200, onboard_status(q.get("log", [""])[0]))
4362
5817
  return
4363
5818
  if self.path in ("/api/candidates", "/api/candidates?fresh=1"):
4364
5819
  self._json(200, onboard_candidates(fresh=self.path.endswith("fresh=1")))
@@ -4366,18 +5821,43 @@ class Handler(gateway.Handler):
4366
5821
  if self.path == "/api/onboard-queue":
4367
5822
  self._json(200, onboard_queue())
4368
5823
  return
5824
+ if self.path == "/api/needs": # after an action there: that page, not the whole one
5825
+ with locked.snapshot():
5826
+ groups, auto = open_actions()
5827
+ self._json(200, {"html": actions_page(groups, auto), "line": actions_line(groups),
5828
+ "count": open_count(groups)})
5829
+ return
5830
+ if path == "/api/pane": # a tab's page, when first opened (bench.js load)
5831
+ key = q.get("tab", [""])[0]
5832
+ if not any(k == key for k, _, _ in TABS):
5833
+ self._json(404, {"ok": False, "errors": [f"no tab {key!r}"]})
5834
+ return
5835
+ with locked.snapshot():
5836
+ try:
5837
+ page = pane(key)
5838
+ except RuntimeError as exc: # endpoints.json unreadable: say which and why, as the page does
5839
+ page = f"<p>{esc(exc)}</p>"
5840
+ self._json(200, {"ok": True, "html": page})
5841
+ return
5842
+ if self.path == "/api/directory": # window.BENCH again, after a retire or restore
5843
+ with locked.snapshot():
5844
+ self._json(200, json.loads(directory_json()))
5845
+ return
4369
5846
  if self.path == "/api/endpoints":
4370
5847
  self._json(200, endpoint_list())
4371
5848
  return
4372
5849
  if self.path == "/api/connect":
4373
5850
  self._json(200, connect_list())
4374
5851
  return
5852
+ if self.path == "/api/connect/local": # Endpoints' "Found on this machine", when it is shown
5853
+ self._json(200, connect_local())
5854
+ return
4375
5855
  if callback:
4376
5856
  # The provider sends the browser back here. A GET that saves, as
4377
5857
  # OAuth callbacks are: the state is single-use and unguessable, and
4378
5858
  # only a sign-in this dashboard started has one. No login cookie:
4379
5859
  # SameSite=Strict keeps it off the provider's cross-site redirect.
4380
- from urllib.parse import parse_qsl, urlparse
5860
+ from urllib.parse import parse_qsl
4381
5861
  query = dict(parse_qsl(urlparse(self.path).query))
4382
5862
  query["state"] = connect.callback_id(self.path, query)
4383
5863
  try:
@@ -4411,50 +5891,63 @@ class Handler(gateway.Handler):
4411
5891
  self._json(200, connections_view())
4412
5892
  return
4413
5893
  if self.path.startswith("/api/discover-model?"):
4414
- from urllib.parse import parse_qs, urlparse
4415
- q = parse_qs(urlparse(self.path).query)
4416
5894
  res = discover_model(q.get("provider", [""])[0], q.get("model", [""])[0])
4417
5895
  self._json(200 if res["ok"] else 404, res)
4418
5896
  return
4419
5897
  if self.path.startswith("/api/gateway-seat?"):
4420
- from urllib.parse import parse_qs, urlparse
4421
- call = parse_qs(urlparse(self.path).query).get("call", [""])[0]
4422
- res = gateway_seat(call)
5898
+ res = gateway_seat(q.get("call", [""])[0])
4423
5899
  self._json(200 if res["ok"] else 404, res)
4424
5900
  return
4425
5901
  if self.path == "/api/stamp":
4426
5902
  self._json(200, {"stamp": data_stamp()})
4427
5903
  return
5904
+ if path == "/api/export": # Settings' Export and import: the zip, as a download
5905
+ scope, keys = q.get("scope", ["settings"])[0], q.get("keys", [""])[0] == "1"
5906
+ if scope not in MOVE_SCOPES or (self.headers.get("Sec-Fetch-Site") or "").lower() == "cross-site":
5907
+ self._json(400, {"ok": False, "errors": ["scope is settings or all, asked from this page"]})
5908
+ return
5909
+ import shutil
5910
+ import tempfile
5911
+ with tempfile.TemporaryFile() as tmp:
5912
+ export_zip(scope, keys, tmp)
5913
+ size = tmp.tell()
5914
+ tmp.seek(0)
5915
+ self.send_response(200)
5916
+ self.send_header("Content-Type", "application/zip")
5917
+ self.send_header("Content-Disposition", f"attachment; filename=\"benchwarmers-{scope}"
5918
+ f"{'-with-keys' if keys else ''}-{datetime.date.today()}.zip\"")
5919
+ self.send_header("Content-Length", str(size))
5920
+ self.send_header("Cache-Control", "no-store")
5921
+ self.send_header("X-Content-Type-Options", "nosniff")
5922
+ self.end_headers()
5923
+ shutil.copyfileobj(tmp, self.wfile)
5924
+ return
4428
5925
  if self.path.startswith(("/fonts/", "/ui/")):
4429
5926
  # Only the named font files and ASSETS; anything else is a 404, so
4430
5927
  # this cannot be walked to other paths.
4431
5928
  name = self.path.split("/", 2)[2]
4432
5929
  path = font_file(name) if self.path.startswith("/fonts/") else \
4433
- (UI / name if name in ASSETS and (UI / name).exists() else None)
5930
+ (UI / name if name in ASSETS and (UI / name).exists() else
5931
+ LOGOS[name] if name in LOGOS and LOGOS[name].exists() else None)
4434
5932
  if not path:
4435
5933
  self.send_error(404)
4436
5934
  return
4437
5935
  data = path.read_bytes()
4438
5936
  self.send_response(200)
4439
- self.send_header("Content-Type", ASSETS.get(name, "font/woff2"))
5937
+ self.send_header("Content-Type", ASSETS.get(name) or ("image/svg+xml" if name in LOGOS else "font/woff2"))
4440
5938
  self.send_header("X-Content-Type-Options", "nosniff")
4441
5939
  self.send_header("Content-Length", str(len(data)))
4442
5940
  self.send_header("Cache-Control", "max-age=86400")
4443
5941
  self.end_headers()
4444
5942
  self.wfile.write(data)
4445
5943
  return
4446
- from urllib.parse import parse_qs, urlparse
4447
- theme = parse_qs(urlparse(self.path).query).get("theme", [None])[0] # a preview; else the saved one
5944
+ theme = q.get("theme", [None])[0] # a preview; else the saved one
5945
+ shown = re.search(r"(?:^|;)\s*bench_tab=(\w+)", self.headers.get("Cookie") or "") # the tab last shown
4448
5946
  try:
4449
- body = render(theme).encode("utf-8")
5947
+ page = render(theme, shown.group(1) if shown else "glance")
4450
5948
  except RuntimeError as exc: # endpoints.json unreadable: say which and why
4451
- body = f"<!doctype html><meta charset='utf-8'><title>Benchwarmers</title><p>{esc(exc)}</p>".encode()
4452
- self.send_response(200)
4453
- self.send_header("Content-Type", "text/html; charset=utf-8")
4454
- self.send_header("Content-Length", str(len(body)))
4455
- self.send_header("Cache-Control", "no-store")
4456
- self.end_headers()
4457
- self.wfile.write(body)
5949
+ page = f"<!doctype html><meta charset='utf-8'><title>Benchwarmers</title><p>{esc(exc)}</p>"
5950
+ self._page(page) # gzipped beyond this machine (_length)
4458
5951
 
4459
5952
  def do_POST(self): # noqa: N802
4460
5953
  """One endpoint, one operation: change a model's role or alias.
@@ -4474,6 +5967,25 @@ class Handler(gateway.Handler):
4474
5967
  _answering[0] -= 1
4475
5968
  if path == "/login":
4476
5969
  return self._login()
5970
+ if path == "/api/import": # Settings' Export and import: the zip as the body
5971
+ try:
5972
+ length = guard.check(self.headers, PORT, post=True, max_body=IMPORT_MAX, kind="application/zip")
5973
+ except guard.Refused as r:
5974
+ return self._json(r.status, {"ok": False, "errors": [str(r)]})
5975
+ if not self._admitted(path):
5976
+ return
5977
+ import tempfile
5978
+ with tempfile.TemporaryFile() as tmp:
5979
+ while length > 0:
5980
+ chunk = self.rfile.read(min(length, 1 << 20))
5981
+ if not chunk:
5982
+ break
5983
+ tmp.write(chunk)
5984
+ length -= len(chunk)
5985
+ tmp.seek(0)
5986
+ result = import_zip(tmp, apply="apply=1" in self.path) if length == 0 else \
5987
+ {"ok": False, "errors": ["the upload stopped before its end"]}
5988
+ return self._json(200 if result["ok"] else 400, result)
4477
5989
  ops = {"/api/model": lambda p: change_model(str(p.get("alias", "")),
4478
5990
  p.get("new_alias"), p.get("new_role"),
4479
5991
  new_ad_hoc=p.get("new_ad_hoc"),
@@ -4529,7 +6041,8 @@ class Handler(gateway.Handler):
4529
6041
  str(p.get("note") or ""), p.get("real"),
4530
6042
  p.get("false")),
4531
6043
  "/api/gateway-settings": lambda p: save_gateway_settings(
4532
- p.get("max_calls"), p.get("max_explorers")),
6044
+ p.get("max_calls"), p.get("max_explorers"), pick_collection=p.get("pick_collection", KEEP),
6045
+ pick_confidence=p.get("pick_confidence", KEEP)),
4533
6046
  "/api/gateway-key": lambda p: make_gateway_key(str(p.get("name") or ""), p.get("collections"),
4534
6047
  p.get("rpm")),
4535
6048
  "/api/gateway-key/remove": lambda p: revoke_gateway_key(str(p.get("name") or "")),
@@ -4597,11 +6110,29 @@ class Handler(gateway.Handler):
4597
6110
  self.send_header("Content-Length", "0")
4598
6111
  self.end_headers()
4599
6112
 
6113
+ def _length(self, body: bytes) -> bytes:
6114
+ """Content-Length, gzipped first when the caller takes gzip and is not
6115
+ on this machine: the page is 2.1 MB, 185 KB gzipped, which a network
6116
+ feels and loopback does not (there it only costs CPU). Returns the
6117
+ bytes to write."""
6118
+ import gzip
6119
+ import ipaddress
6120
+ # gzip as a token, not refused with q=0 ("gzip;q=0" is a no).
6121
+ takes = any(name.strip().lower() == "gzip" and not re.fullmatch(r"\s*q\s*=\s*0(\.0*)?\s*", q)
6122
+ for name, _, q in (p.partition(";") for p in (self.headers.get("Accept-Encoding") or "").split(",")))
6123
+ if (len(body) > 1024 and takes
6124
+ and not ipaddress.ip_address(self.client_address[0].removeprefix("::ffff:")).is_loopback):
6125
+ body = gzip.compress(body, 6)
6126
+ self.send_header("Content-Encoding", "gzip")
6127
+ self.send_header("Vary", "Accept-Encoding")
6128
+ self.send_header("Content-Length", str(len(body)))
6129
+ return body
6130
+
4600
6131
  def _page(self, html_text: str) -> None:
4601
6132
  body = html_text.encode("utf-8")
4602
6133
  self.send_response(200)
4603
6134
  self.send_header("Content-Type", "text/html; charset=utf-8")
4604
- self.send_header("Content-Length", str(len(body)))
6135
+ body = self._length(body)
4605
6136
  self.send_header("Cache-Control", "no-store")
4606
6137
  self.end_headers()
4607
6138
  self.wfile.write(body)
@@ -4610,7 +6141,7 @@ class Handler(gateway.Handler):
4610
6141
  body = json.dumps(data).encode("utf-8")
4611
6142
  self.send_response(status)
4612
6143
  self.send_header("Content-Type", "application/json; charset=utf-8")
4613
- self.send_header("Content-Length", str(len(body)))
6144
+ body = self._length(body)
4614
6145
  self.send_header("Cache-Control", "no-store")
4615
6146
  if cookie:
4616
6147
  self.send_header("Set-Cookie", cookie)