@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/.github/logo-dark.svg +49 -0
- package/.github/logo.svg +49 -0
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +33 -11
- package/PRIVACY.md +20 -6
- package/README.md +46 -11
- package/SECURITY.md +20 -10
- package/USAGE.md +71 -9
- package/VERSION +1 -1
- package/gateway.py +93 -82
- package/metrics/bench_collections.py +27 -3
- package/metrics/catalog.py +52 -4
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/endpoints.py +4 -6
- package/metrics/guard.py +6 -4
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/route.py +9 -1
- package/metrics/scheduler.py +11 -1
- package/metrics/test_decide.py +275 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +44 -3
- package/scripts/test_connect.py +152 -3
- package/scripts/test_first_run.py +4 -2
- package/scripts/test_formats.py +39 -0
- package/scripts/test_serve_edit.py +308 -4
- package/serve.py +1672 -141
- package/suite/README.md +5 -2
- package/suite/run.py +226 -6
- package/suite/test_pick.py +15 -0
- package/suite/test_score.py +82 -1
- package/ui/bench.css +44 -0
- package/ui/bench.js +910 -203
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1389
|
-
it. The built-in client reads the
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
+
|
|
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
|
-
+
|
|
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
|
-
|
|
2396
|
-
|
|
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 = (
|
|
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()
|
|
2417
|
-
|
|
2418
|
-
|
|
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
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
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
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
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(),
|
|
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
|
|
2565
|
-
|
|
2566
|
-
|
|
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
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
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'
|
|
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/<task>/<model>/</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'
|
|
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
|
|
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 ·
|
|
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
|
|
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
|
|
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
|
|
3375
|
-
"
|
|
3376
|
-
"
|
|
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
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
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/<collection>: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}'>{
|
|
3421
|
-
for key, _,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4106
|
-
|
|
4107
|
-
("
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
4447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4452
|
-
self.
|
|
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.
|
|
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.
|
|
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)
|