@wendellresearch-ai/benchwarmers 0.3.3 → 0.5.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/GATEWAY.md +9 -5
- package/PRIVACY.md +9 -3
- package/README.md +31 -8
- package/USAGE.md +81 -15
- package/VERSION +1 -1
- package/cli.py +17 -5
- package/gateway.py +34 -2
- package/launcher.py +2 -1
- package/metrics/aliases.py +24 -3
- package/metrics/discover.py +68 -18
- package/metrics/endpoints.py +36 -4
- package/metrics/model_roles.py +3 -1
- package/metrics/pacing.py +23 -3
- package/metrics/quotaview.py +460 -0
- package/metrics/route.py +3 -2
- package/metrics/scheduler.py +2 -1
- package/metrics/sources.py +29 -6
- package/metrics/wrong.py +3 -1
- package/package.json +1 -1
- package/scripts/check.py +3 -0
- package/scripts/onboard.py +12 -1
- package/scripts/test_hubs.py +10 -5
- package/scripts/test_no_owui.py +14 -2
- package/scripts/test_quota.py +12 -2
- package/scripts/test_serve_edit.py +58 -5
- package/scripts/test_server_json.py +22 -2
- package/serve.py +570 -128
- package/suite/README.md +33 -0
- package/suite/pace.py +8 -4
- package/suite/run.py +144 -16
- package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
- package/suite/tasks/vision-bars/task.json +40 -0
- package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
- package/suite/tasks/vision-invoice/task.json +40 -0
- package/suite/tasks/vision-label/fixtures/label.png +0 -0
- package/suite/tasks/vision-label/task.json +52 -0
- package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
- package/suite/tasks/vision-receipt/task.json +44 -0
- package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
- package/suite/tasks/vision-screen/task.json +48 -0
- package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
- package/suite/tasks/vision-shapes/task.json +40 -0
- package/suite/tasks/vision-table/fixtures/table.png +0 -0
- package/suite/tasks/vision-table/task.json +40 -0
- package/suite/test_vision_tasks.py +201 -0
- package/suite/vision_keys.py +74 -0
- package/suite/vision_sample.py +232 -0
- package/suite/vision_tasks.py +338 -0
- package/ui/bench.css +71 -6
- package/ui/bench.js +543 -49
package/serve.py
CHANGED
|
@@ -43,6 +43,7 @@ import re
|
|
|
43
43
|
import subprocess
|
|
44
44
|
import sys
|
|
45
45
|
import threading
|
|
46
|
+
import time
|
|
46
47
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
48
|
|
|
48
49
|
ROOT = pathlib.Path(__file__).resolve().parent
|
|
@@ -54,6 +55,7 @@ PORT = int(os.environ.get("BENCH_PORT") or 8122)
|
|
|
54
55
|
# Once, at import: roster() used to insert two entries on every page render.
|
|
55
56
|
for _d in ("metrics", "suite", "scripts"):
|
|
56
57
|
sys.path.insert(0, str(ROOT / _d))
|
|
58
|
+
import aliases # noqa: E402
|
|
57
59
|
from aliases import current as current_alias # noqa: E402
|
|
58
60
|
import connect # noqa: E402
|
|
59
61
|
import gateway # noqa: E402 -- its routes are answered on this port too (one port)
|
|
@@ -164,15 +166,25 @@ def suite_scores() -> dict:
|
|
|
164
166
|
The notes are the record, so they are also the source here rather than a
|
|
165
167
|
parallel database that could disagree with them. Later files win, which is
|
|
166
168
|
what makes a re-run show up.
|
|
169
|
+
|
|
170
|
+
Not a vision or image model's (suite/run.images_only): the suite is text
|
|
171
|
+
and code, which it is never seated for, and the answers it gave before it
|
|
172
|
+
had that role stay in the notes, uncounted. Real work, perf and reviews
|
|
173
|
+
still count it. Nor the vision tasks (vision-*): the Vision panel reads
|
|
174
|
+
those (vision_rows).
|
|
167
175
|
"""
|
|
176
|
+
from run import images_only
|
|
168
177
|
row = re.compile(r"^\|\s*([\w-]+)\s*\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|"
|
|
169
178
|
r"\s*([^|]*?)\s*\|\s*([\d.]+)\s*\|")
|
|
170
179
|
scores: dict[tuple[str, str], dict] = {}
|
|
180
|
+
skip = images_only()
|
|
171
181
|
for path in sorted(DATA.glob("runs/suite-*.md")):
|
|
172
182
|
for line in read(path).splitlines():
|
|
173
183
|
if (m := row.match(line.strip())):
|
|
174
184
|
task, model, found, invented, seconds = m.groups()
|
|
175
185
|
model = current_alias(model)
|
|
186
|
+
if model in skip or task.startswith("vision-"):
|
|
187
|
+
continue
|
|
176
188
|
scores[(task, model)] = {
|
|
177
189
|
"found": found, "invented": invented.strip(" —-"),
|
|
178
190
|
"seconds": float(seconds)}
|
|
@@ -209,6 +221,17 @@ def tag_for(llm_id: str) -> str:
|
|
|
209
221
|
return "" if tag == "?" else tag
|
|
210
222
|
|
|
211
223
|
|
|
224
|
+
def provider_label(tag: str) -> str:
|
|
225
|
+
"""A connection tag as a short provider name for a chip: a proxy's vendor
|
|
226
|
+
(cli-proxy-api/codex: codex), else the host without its api./
|
|
227
|
+
router. prefix, -api suffix and TLD (router.requesty.ai: requesty)."""
|
|
228
|
+
if "/" in tag:
|
|
229
|
+
return tag.split("/", 1)[1]
|
|
230
|
+
host = re.sub(r"^(api|apihub|router)\.", "", tag)
|
|
231
|
+
host = host.rsplit(".", 1)[0] if "." in host else host
|
|
232
|
+
return host.removesuffix("-api") or tag
|
|
233
|
+
|
|
234
|
+
|
|
212
235
|
def retired() -> dict[str, dict]:
|
|
213
236
|
"""Aliases removed from the bench by hand, with when and why."""
|
|
214
237
|
return load_json(DATA / "config" / "retired.json")
|
|
@@ -444,6 +467,7 @@ def change_model(alias: str, new_alias: str | None, new_role: str | None,
|
|
|
444
467
|
history = load_json(HISTORY_PATH)
|
|
445
468
|
history[alias] = new_alias
|
|
446
469
|
_write_atomic(HISTORY_PATH, _dump(history))
|
|
470
|
+
aliases.forget() # its one-second cache, so the next read has the rename
|
|
447
471
|
changed.append("config/alias-history.json")
|
|
448
472
|
# 3. configs keyed by alias
|
|
449
473
|
roles = roles_config()
|
|
@@ -773,15 +797,28 @@ def restore_model(alias: str, who: str = "ui") -> dict:
|
|
|
773
797
|
DISMISSED_PATH = DATA / "config" / "dismissed.json"
|
|
774
798
|
|
|
775
799
|
|
|
800
|
+
_modalities_index: tuple = (None, {})
|
|
801
|
+
|
|
802
|
+
|
|
776
803
|
def _modelsdev_modalities() -> dict[str, dict]:
|
|
777
804
|
"""models.dev's cached modalities by comparable model name
|
|
778
|
-
(sources.norm_model),
|
|
805
|
+
(sources.norm_model), not once per row, and built again only when the
|
|
806
|
+
daily fetch rewrites its copy (a second of every picker opening)."""
|
|
807
|
+
global _modalities_index
|
|
779
808
|
import sources
|
|
809
|
+
try:
|
|
810
|
+
st = (sources.CACHE / "modelsdev.json").stat()
|
|
811
|
+
key = (st.st_mtime_ns, st.st_size)
|
|
812
|
+
except OSError:
|
|
813
|
+
key = None
|
|
814
|
+
if key and _modalities_index[0] == key:
|
|
815
|
+
return _modalities_index[1]
|
|
780
816
|
out = {}
|
|
781
817
|
for p in sources.cached("modelsdev").values():
|
|
782
818
|
for k, m in ((p or {}).get("models") or {}).items():
|
|
783
819
|
if (m.get("modalities") or {}).get("input"):
|
|
784
820
|
out.setdefault(sources.norm_model(k), m["modalities"])
|
|
821
|
+
_modalities_index = (key, out)
|
|
785
822
|
return out
|
|
786
823
|
|
|
787
824
|
|
|
@@ -797,11 +834,19 @@ def _modalities(entry: dict, served: str, index: dict) -> dict:
|
|
|
797
834
|
return {"inputs": m.get("input") or [], "outputs": m.get("output") or []}
|
|
798
835
|
|
|
799
836
|
|
|
800
|
-
|
|
837
|
+
# What each endpoint answered, for LISTED_FOR seconds: name -> (when, its
|
|
838
|
+
# config, live, conns). Only the asking is kept; what is on the bench, retired
|
|
839
|
+
# or dismissed is taken off fresh on every opening.
|
|
840
|
+
LISTED_FOR = 3600 # an hour: lists rarely change sooner, and Refresh is one click
|
|
841
|
+
_listed: dict[str, tuple] = {}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def onboard_candidates(fresh: bool = False) -> dict:
|
|
801
845
|
"""What every endpoint serves that the bench could onboard, for the picker.
|
|
802
846
|
|
|
803
|
-
Served, minus registered, minus retired, minus
|
|
804
|
-
separately so a dismissal can be undone),
|
|
847
|
+
Served, minus registered, minus retired, minus queued or onboarding now,
|
|
848
|
+
minus dismissed (those come back separately so a dismissal can be undone),
|
|
849
|
+
each endpoint asked separately
|
|
805
850
|
so one being down only empties its own group. The live calls this page
|
|
806
851
|
makes happen only when the picker opens: a dashboard that needs the
|
|
807
852
|
network to render would be down exactly when you want to look at it.
|
|
@@ -811,8 +856,13 @@ def onboard_candidates() -> dict:
|
|
|
811
856
|
keys): provider-tags.json is written by that script on its schedule, so a
|
|
812
857
|
model added to Open WebUI since was "unknown connection" until it ran.
|
|
813
858
|
The file is the fallback when the hub's connections cannot be read.
|
|
859
|
+
|
|
860
|
+
An endpoint's answer is reused for LISTED_FOR seconds, unless `fresh` (the
|
|
861
|
+
picker's Refresh) or its entry in endpoints.json changed since; a failure
|
|
862
|
+
is never kept. Open WebUI alone can take ten seconds to list.
|
|
814
863
|
"""
|
|
815
|
-
|
|
864
|
+
config = endpoints.load()
|
|
865
|
+
names = list(config)
|
|
816
866
|
if not names:
|
|
817
867
|
return {"ok": False, "errors": ["no endpoint yet: add one first (Endpoints, then Manage, then"
|
|
818
868
|
" Add an endpoint), and what it serves is listed here"]}
|
|
@@ -820,6 +870,9 @@ def onboard_candidates() -> dict:
|
|
|
820
870
|
| {endpoints.snapshot_key(r.get("served", ""), r.get("endpoint") or endpoints.default())
|
|
821
871
|
for r in retired().values()}
|
|
822
872
|
| {endpoints.snapshot_key(d["served"], d["endpoint"]) for d in decide.load().values()})
|
|
873
|
+
with _OQ_LOCK: # on its way: Start would refuse it anyway
|
|
874
|
+
taken |= {endpoints.snapshot_key(i["served"], i["endpoint"]) for i in _OQ["items"]
|
|
875
|
+
if i["state"] in ("queued", "running")}
|
|
823
876
|
tags = load_json(DATA / "config" / "provider-tags.json")
|
|
824
877
|
dismissed = load_json(DISMISSED_PATH)
|
|
825
878
|
first_seen = load_json(DATA / "config" / "served-snapshot.json").get("last_seen", {})
|
|
@@ -827,10 +880,13 @@ def onboard_candidates() -> dict:
|
|
|
827
880
|
from concurrent.futures import ThreadPoolExecutor
|
|
828
881
|
|
|
829
882
|
def ask(name: str):
|
|
883
|
+
kept = _listed.get(name)
|
|
884
|
+
if not fresh and kept and kept[1] == config[name] and time.time() - kept[0] < LISTED_FOR:
|
|
885
|
+
return kept[2], kept[3], kept[0]
|
|
830
886
|
try:
|
|
831
887
|
live = endpoints.models(name)
|
|
832
888
|
except Exception as exc: # noqa: BLE001
|
|
833
|
-
return exc, []
|
|
889
|
+
return exc, [], 0
|
|
834
890
|
conns = []
|
|
835
891
|
if endpoints.hub(name) == "openwebui":
|
|
836
892
|
import provider_tags
|
|
@@ -838,11 +894,14 @@ def onboard_candidates() -> dict:
|
|
|
838
894
|
conns = provider_tags.connection_names(name)
|
|
839
895
|
except Exception: # noqa: BLE001 -- the tags file answers instead
|
|
840
896
|
pass
|
|
841
|
-
|
|
897
|
+
_listed[name] = (time.time(), config[name], live, conns)
|
|
898
|
+
return live, conns, _listed[name][0]
|
|
842
899
|
with ThreadPoolExecutor(max_workers=len(names)) as pool: # all at once, not in turn
|
|
843
900
|
answers = dict(zip(names, pool.map(ask, names)))
|
|
901
|
+
# The oldest answer shown, so the picker can say how old its list is.
|
|
902
|
+
listed = min((at for _, _, at in answers.values() if at), default=time.time())
|
|
844
903
|
mods = _modelsdev_modalities()
|
|
845
|
-
for name, (live, conns) in answers.items():
|
|
904
|
+
for name, (live, conns, _) in answers.items():
|
|
846
905
|
if isinstance(live, Exception):
|
|
847
906
|
errors.append(f"cannot reach {name}: {live}")
|
|
848
907
|
continue
|
|
@@ -869,7 +928,8 @@ def onboard_candidates() -> dict:
|
|
|
869
928
|
(gone if key in dismissed else open_).append(row)
|
|
870
929
|
if not open_ and not gone and errors:
|
|
871
930
|
return {"ok": False, "errors": errors}
|
|
872
|
-
return {"ok": True, "errors": errors, "candidates": open_, "dismissed": gone
|
|
931
|
+
return {"ok": True, "errors": errors, "candidates": open_, "dismissed": gone,
|
|
932
|
+
"age": round(time.time() - listed)}
|
|
873
933
|
|
|
874
934
|
|
|
875
935
|
def dismiss(served, undo: bool = False, who: str = "ui") -> dict:
|
|
@@ -1412,12 +1472,18 @@ def collections_view() -> dict:
|
|
|
1412
1472
|
# What the tags check members against (bench.js tagBlocks): capabilities
|
|
1413
1473
|
# (None: the catalog does not say), context, price and role. A shape's
|
|
1414
1474
|
# tag admits the roles that shape admits, as route.py does for seats.
|
|
1475
|
+
# The member picker draws one row per model, its providers as chips:
|
|
1476
|
+
# `family` is route.family_of, the match that keeps one model out of two
|
|
1477
|
+
# seats, and `provider` the chip's name for its connection.
|
|
1478
|
+
import route
|
|
1415
1479
|
roles = model_roles.load()
|
|
1416
1480
|
facts = {}
|
|
1417
1481
|
for a in _chat_aliases():
|
|
1418
1482
|
f = catalog.info(a)
|
|
1483
|
+
tag = directory[a].get("tag") or directory[a].get("endpoint") or ""
|
|
1419
1484
|
facts[a] = {"caps": {c: f.get(c) for c in catalog.CAPS}, "context": f.get("context_window"),
|
|
1420
|
-
"price": price_of(a, served[a], f), "role": (roles.get("models") or {}).get(a, "general")
|
|
1485
|
+
"price": price_of(a, served[a], f), "role": (roles.get("models") or {}).get(a, "general"),
|
|
1486
|
+
"family": route.family_of(a), "provider": provider_label(tag), "connection": tag}
|
|
1421
1487
|
return {"ok": True, "errors": [], "collections": rows, "settings": s,
|
|
1422
1488
|
"shapes": shapes, "models": _chat_aliases(), "served": served, "tag_suggestions": suggestions,
|
|
1423
1489
|
"facts": facts, "shape_roles": {k: v for k, v in roles["shapes"].items() if k in shapes},
|
|
@@ -1522,13 +1588,33 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
|
|
|
1522
1588
|
|
|
1523
1589
|
# How much an onboarding spends. Full: the 4-call probe, then the suite now.
|
|
1524
1590
|
# Quick: the probe only; the scheduled `suite --next` passes fill the suite in,
|
|
1525
|
-
# six tasks a run, so the same calls are spread out, not saved.
|
|
1526
|
-
#
|
|
1527
|
-
#
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1591
|
+
# six tasks a run, so the same calls are spread out, not saved. Ad hoc: the
|
|
1592
|
+
# bench never calls it on its own afterwards, for a metered, capped or trial
|
|
1593
|
+
# key; Full + ad hoc spends the suite once now so routing has scores for it,
|
|
1594
|
+
# Quick + ad hoc only the probe, and no scheduled pass ever fills its suite in
|
|
1595
|
+
# (suite/pace.py leaves ad hoc models out). Apart from the mode,
|
|
1596
|
+
# `vision` (the picker's Vision model box) gives it role vision as it is
|
|
1597
|
+
# registered: a seat only for requests that carry images, and its suite is the
|
|
1598
|
+
# vision tasks alone (suite/run.applies), now or through the scheduled passes.
|
|
1599
|
+
ONBOARD_MODES = {"full": [], "full-adhoc": ["--ad-hoc"], "quick": ["--skip-suite"],
|
|
1600
|
+
"quick-adhoc": ["--skip-suite", "--ad-hoc"]}
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def onboard_choice(prefix: str) -> str:
|
|
1604
|
+
"""The mode and the Vision box, as the Onboard dialog (o-) and Discover's Add confirm (ask-) show them."""
|
|
1605
|
+
return (f"<label class='check'>Onboard as <select id='{prefix}-mode'>"
|
|
1606
|
+
"<option value='full'>Full: probe, then the suite now</option>"
|
|
1607
|
+
"<option value='full-adhoc'>Full + ad hoc: probe and the suite now; then the bench never calls it"
|
|
1608
|
+
" on its own</option>"
|
|
1609
|
+
"<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
|
|
1610
|
+
"<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
|
|
1611
|
+
" (metered, capped or trial keys)</option></select></label>"
|
|
1612
|
+
f"<label class='check'><input type='checkbox' id='{prefix}-vision'> Vision model: a seat only for"
|
|
1613
|
+
" questions about images, and its suite is the vision tasks</label>")
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str = "full",
|
|
1617
|
+
vision: bool = False) -> dict:
|
|
1532
1618
|
"""Kick off `scripts/onboard.py` for a served model, detached, logging to runs/.
|
|
1533
1619
|
|
|
1534
1620
|
The script does its own checks in the right order (is it served, derive
|
|
@@ -1538,7 +1624,7 @@ def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str =
|
|
|
1538
1624
|
hands the rest to the script. It runs for minutes and spends calls, so it
|
|
1539
1625
|
runs in the background and the page tails its log.
|
|
1540
1626
|
"""
|
|
1541
|
-
return _spawn_onboard(served, endpoint, who, mode)[0]
|
|
1627
|
+
return _spawn_onboard(served, endpoint, who, mode, vision)[0]
|
|
1542
1628
|
|
|
1543
1629
|
|
|
1544
1630
|
def _onboard_refusal(served: str, endpoint: str, mode: str = "full") -> list[str]:
|
|
@@ -1567,7 +1653,7 @@ def _onboard_refusal(served: str, endpoint: str, mode: str = "full") -> list[str
|
|
|
1567
1653
|
|
|
1568
1654
|
|
|
1569
1655
|
def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui",
|
|
1570
|
-
mode: str = "full") -> tuple[dict, subprocess.Popen | None]:
|
|
1656
|
+
mode: str = "full", vision: bool = False) -> tuple[dict, subprocess.Popen | None]:
|
|
1571
1657
|
"""start_onboard's result, and the process (None when refused) for a caller that waits on it."""
|
|
1572
1658
|
served = (served or "").strip()
|
|
1573
1659
|
endpoint = (endpoint or "").strip() or endpoints.default()
|
|
@@ -1579,7 +1665,7 @@ def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui",
|
|
|
1579
1665
|
log = DATA / "runs" / f"onboard-{stamp}-{safe}.log"
|
|
1580
1666
|
from run import bench_python
|
|
1581
1667
|
cmd = [bench_python(), str(ROOT / "scripts" / "onboard.py"), served, "--endpoint", endpoint,
|
|
1582
|
-
*ONBOARD_MODES[mode]]
|
|
1668
|
+
*ONBOARD_MODES[mode], *(["--vision"] if vision else [])]
|
|
1583
1669
|
# The log handle goes straight to the child, unbuffered, so the page sees
|
|
1584
1670
|
# each step as it prints. The old `cmd /c ... > log & echo` wrapper with
|
|
1585
1671
|
# DETACHED_PROCESS sent the output to a console window instead, so every
|
|
@@ -1648,19 +1734,19 @@ def queue_onboard(items, who: str = "ui") -> dict:
|
|
|
1648
1734
|
endpoint = str(it.get("endpoint") or "").strip() or endpoints.default()
|
|
1649
1735
|
mode = str(it.get("mode") or "full")
|
|
1650
1736
|
errors += _onboard_refusal(served, endpoint, mode)
|
|
1651
|
-
picked.append((served, endpoint, mode))
|
|
1737
|
+
picked.append((served, endpoint, mode, it.get("vision") is True))
|
|
1652
1738
|
with _OQ_LOCK:
|
|
1653
1739
|
waiting = {(i["served"], i["endpoint"]) for i in _OQ["items"] if i["state"] in ("queued", "running")}
|
|
1654
|
-
errors += [f"{s} from {e} is already in the queue" for s, e, _ in picked if (s, e) in waiting]
|
|
1655
|
-
if len({(s, e) for s, e, _ in picked}) < len(picked):
|
|
1740
|
+
errors += [f"{s} from {e} is already in the queue" for s, e, *_ in picked if (s, e) in waiting]
|
|
1741
|
+
if len({(s, e) for s, e, *_ in picked}) < len(picked):
|
|
1656
1742
|
errors.append("a model is picked twice")
|
|
1657
1743
|
if errors:
|
|
1658
1744
|
return {"ok": False, "errors": errors}
|
|
1659
1745
|
if _OQ["worker"] is None:
|
|
1660
1746
|
_OQ["items"] = []
|
|
1661
1747
|
_OQ["stop"] = False # added after a Stop: these run
|
|
1662
|
-
_OQ["items"] += [{"served": s, "endpoint": e, "mode": m, "
|
|
1663
|
-
"exit": None, "note": ""} for s, e, m in picked]
|
|
1748
|
+
_OQ["items"] += [{"served": s, "endpoint": e, "mode": m, "vision": v, "state": "queued", "alias": None,
|
|
1749
|
+
"log": None, "exit": None, "note": ""} for s, e, m, v in picked]
|
|
1664
1750
|
if _OQ["worker"] is None:
|
|
1665
1751
|
_OQ["worker"] = threading.Thread(target=_onboard_worker, args=(who,), daemon=True)
|
|
1666
1752
|
_OQ["worker"].start()
|
|
@@ -1712,7 +1798,8 @@ def _onboard_worker(who: str) -> None:
|
|
|
1712
1798
|
return
|
|
1713
1799
|
nxt["state"] = "running"
|
|
1714
1800
|
try:
|
|
1715
|
-
res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who, nxt.get("mode", "full")
|
|
1801
|
+
res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who, nxt.get("mode", "full"),
|
|
1802
|
+
nxt.get("vision", False))
|
|
1716
1803
|
if proc is None: # refused now: the bench changed since it was queued
|
|
1717
1804
|
state, note, code = "failed", "; ".join(res["errors"]), None
|
|
1718
1805
|
else:
|
|
@@ -1730,13 +1817,17 @@ def _onboard_worker(who: str) -> None:
|
|
|
1730
1817
|
nxt.update(state=state, note=note, exit=code)
|
|
1731
1818
|
|
|
1732
1819
|
|
|
1733
|
-
def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = ""
|
|
1820
|
+
def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = "",
|
|
1821
|
+
mode: str = "full", vision: bool = False) -> dict:
|
|
1734
1822
|
"""From the Discover tab: put a model into a hub as it is otherwise done by
|
|
1735
1823
|
hand, through the adapter of the hub `endpoint` is (endpoints.adapter:
|
|
1736
1824
|
Open WebUI's connection Model IDs and a public, tagged record with a logo;
|
|
1737
1825
|
a LiteLLM deployment; a CLI Proxy API provider's models list), then the
|
|
1738
|
-
normal onboarding
|
|
1826
|
+
normal onboarding, in the mode and with the Vision box the confirm chose
|
|
1827
|
+
(ONBOARD_MODES). Only for a connection the hub already has. No endpoint
|
|
1739
1828
|
means Open WebUI's, as before there were other hubs."""
|
|
1829
|
+
if mode not in ONBOARD_MODES: # before the hub has it, not after
|
|
1830
|
+
return {"ok": False, "errors": [f"mode must be one of {', '.join(ONBOARD_MODES)}; got {mode!r}"]}
|
|
1740
1831
|
if not re.fullmatch(r"[\w./:@+-]{1,200}", model or ""):
|
|
1741
1832
|
return {"ok": False, "errors": ["the model id is the provider's id, e.g. vendor/model:free"]}
|
|
1742
1833
|
if not re.fullmatch(r"[\w.-]{1,80}", connection or ""):
|
|
@@ -1755,7 +1846,7 @@ def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = "
|
|
|
1755
1846
|
return {"ok": False, "errors": [f"{label} refused: {type(exc).__name__}: {exc}"]}
|
|
1756
1847
|
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "",
|
|
1757
1848
|
f"{which}-add", None, f"{served} on {connection}")
|
|
1758
|
-
started = queue_onboard([{"served": served, "endpoint": hub.endpoint()}], who)
|
|
1849
|
+
started = queue_onboard([{"served": served, "endpoint": hub.endpoint(), "mode": mode, "vision": vision}], who)
|
|
1759
1850
|
return {**started, "served": served, "endpoint": hub.endpoint(), "steps": steps,
|
|
1760
1851
|
"errors": started["errors"] + ([] if started["ok"] else
|
|
1761
1852
|
[f"{label} now has {served}; onboard it from the Onboard dialog"])}
|
|
@@ -1914,14 +2005,26 @@ def problems() -> str:
|
|
|
1914
2005
|
KIND = {"retiring": "err", "retired": "err", "connection": "warn",
|
|
1915
2006
|
"registration": "warn", "plan": "warn"}
|
|
1916
2007
|
items = []
|
|
2008
|
+
live = set(names.values())
|
|
1917
2009
|
for key, a in sorted(alerts.items(), key=lambda kv: kv[1].get("first_seen", "")):
|
|
1918
2010
|
served = key.split(":", 2)[2] if key.count(":") >= 2 else ""
|
|
1919
2011
|
alias = a.get("alias") or names.get(served, "")
|
|
2012
|
+
# The alerts file is rewritten by the daily check; until then an alert
|
|
2013
|
+
# whose models have all left the bench (Retire all, or one by one) has
|
|
2014
|
+
# nothing left to act on.
|
|
2015
|
+
if a.get("aliases") and not live & set(a["aliases"]):
|
|
2016
|
+
continue
|
|
2017
|
+
if a.get("kind") == "retiring" and alias and alias not in live:
|
|
2018
|
+
continue
|
|
1920
2019
|
try:
|
|
1921
2020
|
age = (today - datetime.date.fromisoformat(a["first_seen"])).days
|
|
1922
2021
|
except (KeyError, ValueError):
|
|
1923
2022
|
age = 0
|
|
1924
|
-
|
|
2023
|
+
# Which model and where it comes from, first: the message alone left that to be looked up.
|
|
2024
|
+
served_as = next((s for s, al in names.items() if al == alias), served) if alias else ""
|
|
2025
|
+
where = (f"{alias} · {served_as or '?'} via {tag_for(alias) or a.get('endpoint') or '?'}"
|
|
2026
|
+
if alias else "")
|
|
2027
|
+
why = " · ".join(filter(None, [where, a.get("action", ""),
|
|
1925
2028
|
"open today" if age == 0 else f"open {age} days"]))
|
|
1926
2029
|
acts = []
|
|
1927
2030
|
if a.get("aliases"):
|
|
@@ -1938,7 +2041,10 @@ def problems() -> str:
|
|
|
1938
2041
|
items.append((key, a.get("kind", "?"), KIND.get(a.get("kind"), "warn"),
|
|
1939
2042
|
a.get("message", ""), why, "".join(acts)))
|
|
1940
2043
|
cutoff = (today - datetime.timedelta(days=7)).isoformat()
|
|
1941
|
-
|
|
2044
|
+
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2045
|
+
auto = [(f"retired:{alias}:{r['at']}", "", "",
|
|
2046
|
+
f"{alias} · {r.get('served') or '?'} via {tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default()}"
|
|
2047
|
+
f" · {r['at'][:10]}",
|
|
1942
2048
|
r.get("why", "").removeprefix("auto: "),
|
|
1943
2049
|
f"<button class='btn small' data-act='restore' data-alias='{esc(alias)}'>Restore</button>")
|
|
1944
2050
|
for alias, r in sorted(retired().items(), key=lambda kv: kv[1].get("at", ""), reverse=True)
|
|
@@ -2154,14 +2260,15 @@ def on_bench() -> set[str]:
|
|
|
2154
2260
|
return set(registered().values())
|
|
2155
2261
|
|
|
2156
2262
|
|
|
2157
|
-
def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = ""
|
|
2263
|
+
def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = "",
|
|
2264
|
+
retired: bool = True) -> str:
|
|
2158
2265
|
"""A Benchmarks panel: the chart first (drawn by bench.js with Observable
|
|
2159
2266
|
Plot), the table folded under it, the key under that."""
|
|
2160
2267
|
return (f"<div class='panel'><h2>{title}</h2><p class='sub'>{sub}</p>{extra}"
|
|
2161
2268
|
f"<div class='chart' id='{chart_id}'></div>"
|
|
2162
2269
|
"<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
|
|
2163
2270
|
" data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
|
|
2164
|
-
"<label><input type='checkbox' data-retired> Show retired</label
|
|
2271
|
+
+ ("<label><input type='checkbox' data-retired> Show retired</label>" if retired else "") + "</div>"
|
|
2165
2272
|
f"<div class='scroll'>{table}</div></details>"
|
|
2166
2273
|
f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
|
|
2167
2274
|
|
|
@@ -2174,13 +2281,34 @@ def _family(task: str) -> str:
|
|
|
2174
2281
|
return "hand-built"
|
|
2175
2282
|
|
|
2176
2283
|
|
|
2284
|
+
_wrong: tuple = (0.0, {})
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def wrong_table() -> dict:
|
|
2288
|
+
"""wrong.table(), shared by the three cards of one render: it scores every
|
|
2289
|
+
suite answer, and asking three times was a second of each reload."""
|
|
2290
|
+
global _wrong
|
|
2291
|
+
import wrong
|
|
2292
|
+
from run import images_only
|
|
2293
|
+
seen = _wrong
|
|
2294
|
+
if time.monotonic() - seen[0] > 5:
|
|
2295
|
+
table = wrong.table()
|
|
2296
|
+
# A vision or image model keeps its reviews (real work, judged) but not
|
|
2297
|
+
# its code-task answers: the suite is not what it is for (suite_scores).
|
|
2298
|
+
for alias in images_only() & set(table):
|
|
2299
|
+
r = table[alias]
|
|
2300
|
+
r.update(answers=0, wrong=0, by={k: [0, 0] for k in r["by"]}, hunt=dict.fromkeys(r["hunt"], 0))
|
|
2301
|
+
seen = _wrong = (time.monotonic(), table) # stamped once scored, not before
|
|
2302
|
+
return seen[1]
|
|
2303
|
+
|
|
2304
|
+
|
|
2177
2305
|
def charts_data() -> dict:
|
|
2178
2306
|
"""Everything the Benchmarks charts draw, for bench.js. Registered models
|
|
2179
2307
|
only: the tables keep retired ones behind "Show retired", a chart has no toggle."""
|
|
2180
2308
|
import route
|
|
2181
2309
|
import wrong
|
|
2182
2310
|
live = on_bench()
|
|
2183
|
-
recall, wr = route.suite_recall(),
|
|
2311
|
+
recall, wr = route.suite_recall(), wrong_table()
|
|
2184
2312
|
overview = [{"model": m, "recall": recall[m][0] / recall[m][1], "wrong": wr[m]["wrong"] / wr[m]["answers"],
|
|
2185
2313
|
"found": f"{recall[m][0]}/{recall[m][1]}", "wrongN": f"{wr[m]['wrong']}/{wr[m]['answers']}"}
|
|
2186
2314
|
for m in sorted(live) if recall.get(m) and recall[m][1] and wr.get(m, {}).get("answers")]
|
|
@@ -2198,7 +2326,7 @@ def charts_data() -> dict:
|
|
|
2198
2326
|
return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
|
|
2199
2327
|
"seeded, whole file", "real bugs"],
|
|
2200
2328
|
"wrong": bars, "wrongTotals": totals, "kinds": list(wrong.KINDS), "hunt": hunt,
|
|
2201
|
-
"work": work_rows(), **perf_charts()}
|
|
2329
|
+
"work": work_rows(), "vision": vision_rows(), **perf_charts()}
|
|
2202
2330
|
|
|
2203
2331
|
|
|
2204
2332
|
def work_rows() -> list[dict]:
|
|
@@ -2215,6 +2343,184 @@ def work_rows() -> list[dict]:
|
|
|
2215
2343
|
for m, s in sorted(summary().items()) if m in live and s["judged"]]
|
|
2216
2344
|
|
|
2217
2345
|
|
|
2346
|
+
VISION_STATES = ("right", "misread", "left out", "trap taken", "no answer")
|
|
2347
|
+
VISION_SEATS = ("checked, helped", "checked, did not help", "useful, no claims", "no use, no claims",
|
|
2348
|
+
"not judged", "no answer")
|
|
2349
|
+
_vision_work: tuple = (None, {})
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
def vision_rows() -> dict:
|
|
2353
|
+
"""The Vision panel's evidence, in two kinds kept apart.
|
|
2354
|
+
|
|
2355
|
+
TASKS: the vision tasks' saved answers (suite/tasks/vision-*), scored by
|
|
2356
|
+
the suite's own scorer and then field by field: right, misread (the line
|
|
2357
|
+
is there with another value), left out, trap taken (a value for something
|
|
2358
|
+
the picture does not show, or a switch that is off called on), no answer.
|
|
2359
|
+
The keys exist by construction (suite/vision_tasks.py), so this is the
|
|
2360
|
+
controlled comparison.
|
|
2361
|
+
|
|
2362
|
+
REAL WORK: every seat of the gateway's vision panels (runs/gateway/
|
|
2363
|
+
calls.jsonl, shape vision) and how it was judged (metrics/offloads.jsonl).
|
|
2364
|
+
A verdict that lists claims was checked claim by claim against the
|
|
2365
|
+
picture; one without claims says only useful or not, which for thicken's
|
|
2366
|
+
face corners means it agreed with another seat. Agreement is not a check
|
|
2367
|
+
(the bench's own rule: seats fail together), so the two are never added.
|
|
2368
|
+
"""
|
|
2369
|
+
import run
|
|
2370
|
+
live = on_bench()
|
|
2371
|
+
heat, tasks = [], {}
|
|
2372
|
+
for path in sorted((DATA / "suite" / "tasks").glob("vision-*/task.json")):
|
|
2373
|
+
spec = json.loads(read(path))
|
|
2374
|
+
if spec.get("draft"):
|
|
2375
|
+
continue
|
|
2376
|
+
names = list(dict.fromkeys(c["label"].split(":")[0] for c in spec["must_find"]))
|
|
2377
|
+
traps = {c["label"].split(":")[0] for c in spec.get("must_not_claim", [])}
|
|
2378
|
+
tasks[spec["id"]] = {"title": spec["title"], "fields": names, "family": _vision_family(spec)}
|
|
2379
|
+
for answer in sorted((path.parent / "output").glob("*.md")):
|
|
2380
|
+
alias = current_alias(answer.stem)
|
|
2381
|
+
if alias not in live:
|
|
2382
|
+
continue
|
|
2383
|
+
text = read(answer)
|
|
2384
|
+
s = run.score(spec, text)
|
|
2385
|
+
missed = {lbl.split(":")[0] for lbl in s["missed"]}
|
|
2386
|
+
invented = {lbl.split(":")[0] for lbl in s["invented"]}
|
|
2387
|
+
for name in names:
|
|
2388
|
+
there = re.search(rf"(?m)^[^\w\n]*{re.escape(name).replace('_', '[ _]')}[^\w\n]*:", text.lower())
|
|
2389
|
+
state = ("no answer" if s["kind"] != "answered" else "trap taken" if name in invented
|
|
2390
|
+
else "right" if name not in missed else "misread" if there else "left out")
|
|
2391
|
+
heat.append({"model": alias, "task": spec["id"], "field": name, "key": f"{spec['id']}·{name}",
|
|
2392
|
+
"state": state, "trap": name in traps})
|
|
2393
|
+
models: dict[str, dict] = {}
|
|
2394
|
+
for h in heat:
|
|
2395
|
+
m = models.setdefault(h["model"], {"asked": set(), "answered": set(), "fields": 0, "right": 0,
|
|
2396
|
+
"traps": 0, "taken": 0})
|
|
2397
|
+
m["asked"].add(h["task"])
|
|
2398
|
+
if h["state"] != "no answer":
|
|
2399
|
+
m["answered"].add(h["task"])
|
|
2400
|
+
m["fields"] += 1
|
|
2401
|
+
m["right"] += h["state"] == "right"
|
|
2402
|
+
m["traps"] += h["trap"]
|
|
2403
|
+
m["taken"] += h["state"] == "trap taken"
|
|
2404
|
+
summary = {a: {"asked": len(m["asked"]), "answered": len(m["answered"]), "fields": m["fields"],
|
|
2405
|
+
"right": m["right"], "traps": m["traps"], "taken": m["taken"]} for a, m in models.items()}
|
|
2406
|
+
return {"tasks": tasks, "heat": heat, "summary": summary, "states": list(VISION_STATES),
|
|
2407
|
+
"seatKinds": list(VISION_SEATS), "work": _vision_work_rows(live), "held": run.vision_held()}
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
VISION_FAMILIES = (("vision-cord-", "receipts"), ("vision-screenqa-", "screens"), ("vision-tablevqa-", "tables"),
|
|
2411
|
+
("vision-pope-", "objects"))
|
|
2412
|
+
|
|
2413
|
+
|
|
2414
|
+
def _vision_family(spec: dict) -> str:
|
|
2415
|
+
"""The grid's group: a public source (suite/vision_sample.py), the made
|
|
2416
|
+
pictures (suite/vision_tasks.py), or the install's own (written by hand)."""
|
|
2417
|
+
if spec.get("made_by", "").startswith("suite/vision_tasks.py"):
|
|
2418
|
+
return "made"
|
|
2419
|
+
return next((name for prefix, name in VISION_FAMILIES if spec["id"].startswith(prefix)), "own")
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def _vision_work_rows(live: set[str]) -> dict:
|
|
2423
|
+
"""alias -> {seat kind: count, claims right, claims wrong, seconds}; read
|
|
2424
|
+
again only when the gateway's log or the verdicts change (5 MB a render)."""
|
|
2425
|
+
global _vision_work
|
|
2426
|
+
calls, verdicts = DATA / "runs" / "gateway" / "calls.jsonl", DATA / "metrics" / "offloads.jsonl"
|
|
2427
|
+
try:
|
|
2428
|
+
key = tuple((p.stat().st_mtime_ns, p.stat().st_size) for p in (calls, verdicts)) + (frozenset(live),)
|
|
2429
|
+
except OSError:
|
|
2430
|
+
return {}
|
|
2431
|
+
if _vision_work[0] == key:
|
|
2432
|
+
return _vision_work[1]
|
|
2433
|
+
from offload import entries
|
|
2434
|
+
judged = {e["call"]: e for e in entries() if e.get("call") and e.get("useful") is not None}
|
|
2435
|
+
out: dict[str, dict] = {}
|
|
2436
|
+
for raw in read(calls).splitlines():
|
|
2437
|
+
try:
|
|
2438
|
+
panel = json.loads(raw)
|
|
2439
|
+
except ValueError:
|
|
2440
|
+
continue
|
|
2441
|
+
if panel.get("shape") != "vision":
|
|
2442
|
+
continue
|
|
2443
|
+
for s in panel.get("seats", []):
|
|
2444
|
+
alias = current_alias(s.get("alias", ""))
|
|
2445
|
+
if alias not in live:
|
|
2446
|
+
continue
|
|
2447
|
+
r = out.setdefault(alias, {**dict.fromkeys(VISION_SEATS, 0), "real": 0, "false": 0, "seconds": []})
|
|
2448
|
+
v = judged.get(s.get("call"))
|
|
2449
|
+
if s.get("status") != "ok" or not (s.get("answer") or "").strip():
|
|
2450
|
+
kind = "no answer"
|
|
2451
|
+
elif v is None:
|
|
2452
|
+
kind = "not judged"
|
|
2453
|
+
elif v.get("real") or v.get("false"):
|
|
2454
|
+
kind = "checked, helped" if v["useful"] else "checked, did not help"
|
|
2455
|
+
r["real"] += len(v.get("real") or [])
|
|
2456
|
+
r["false"] += len(v.get("false") or [])
|
|
2457
|
+
else:
|
|
2458
|
+
kind = "useful, no claims" if v["useful"] else "no use, no claims"
|
|
2459
|
+
r[kind] += 1
|
|
2460
|
+
if kind != "no answer" and s.get("seconds"):
|
|
2461
|
+
r["seconds"].append(float(s["seconds"]))
|
|
2462
|
+
for r in out.values():
|
|
2463
|
+
secs = sorted(r.pop("seconds"))
|
|
2464
|
+
r["seconds"] = secs[len(secs) // 2] if secs else None
|
|
2465
|
+
_vision_work = (key, out)
|
|
2466
|
+
return out
|
|
2467
|
+
|
|
2468
|
+
|
|
2469
|
+
def vision_card() -> str:
|
|
2470
|
+
"""Reading images: the vision tasks, then real work on images."""
|
|
2471
|
+
v = vision_rows()
|
|
2472
|
+
s, w = v["summary"], v["work"]
|
|
2473
|
+
body = ["<table><thead><tr>", th("Model"), th("Tasks answered", True), th("Fields read right", True),
|
|
2474
|
+
th("Traps taken", True), th("Image seats", True), th("Checked claims right", True),
|
|
2475
|
+
th("Checked claims wrong", True), th("Useful, no claims", True), th("Not judged", True),
|
|
2476
|
+
th("Median seconds", True), "</tr></thead><tbody>"]
|
|
2477
|
+
|
|
2478
|
+
def cell(n, d):
|
|
2479
|
+
return (f"<td class='num' data-sort='{n / d:.3f}'>{n}/{d}</td>" if d
|
|
2480
|
+
else "<td class='num muted' data-sort='-1'>—</td>")
|
|
2481
|
+
for m in sorted(set(s) | set(w)):
|
|
2482
|
+
t, r = s.get(m, {}), w.get(m, {})
|
|
2483
|
+
seats = sum(r.get(k, 0) for k in VISION_SEATS)
|
|
2484
|
+
claims = r.get("real", 0) + r.get("false", 0)
|
|
2485
|
+
body.append(f"{tr(m)}<td>{named(m)}</td>" + cell(t.get("answered", 0), t.get("asked", 0))
|
|
2486
|
+
+ cell(t.get("right", 0), t.get("fields", 0)) + cell(t.get("taken", 0), t.get("traps", 0))
|
|
2487
|
+
+ f"<td class='num'>{seats or '—'}</td>" + cell(r.get("real", 0), claims)
|
|
2488
|
+
+ cell(r.get("false", 0), claims) + f"<td class='num'>{r.get('useful, no claims', 0) or '—'}</td>"
|
|
2489
|
+
+ f"<td class='num'>{r.get('not judged', 0) or '—'}</td>"
|
|
2490
|
+
+ (f"<td class='num'>{r['seconds']:.1f}</td>" if r.get("seconds") else "<td class='num muted'>—</td>")
|
|
2491
|
+
+ "</tr>")
|
|
2492
|
+
body.append("</tbody></table>")
|
|
2493
|
+
key = """<dl>
|
|
2494
|
+
<dt>The vision tasks</dt><dd>Grouped by where the pictures come from: <b>made</b> with their answer
|
|
2495
|
+
key (<code>suite/vision_tasks.py</code>), your <b>own</b> photos keyed by hand, and items sampled
|
|
2496
|
+
from public datasets with people's answers (<code>suite/vision_sample.py</code>): photographed
|
|
2497
|
+
<b>receipts</b>, app <b>screens</b>, <b>tables</b>, and <b>objects</b> in photos. Each field is
|
|
2498
|
+
<b>right</b>, <b>misread</b> (another value), <b>left out</b>, or a <b>trap taken</b>: a value for
|
|
2499
|
+
something the picture does not show, where the right answer is <code>not shown</code> (a dot marks
|
|
2500
|
+
those fields). They run for every model that takes images; a vision model gets only these, never
|
|
2501
|
+
the code tasks.</dd>
|
|
2502
|
+
<dt>Real work on images</dt><dd>Every seat of a vision panel through the gateway. <b>Checked</b> means
|
|
2503
|
+
its verdict lists claims, each compared with the picture by hand: the claims right and wrong
|
|
2504
|
+
come from these alone. <b>Useful, no claims</b> is a verdict without them, which on thicken's
|
|
2505
|
+
photos means the seat agreed with another seat. Agreement is not a check, since seats fail
|
|
2506
|
+
together, so it is never counted as right.</dd>
|
|
2507
|
+
<dt>The number beside a name</dt><dd>How much there is: fields asked on the tasks, seats on real work.</dd>
|
|
2508
|
+
</dl>"""
|
|
2509
|
+
held = (f"<p class='hint'>The vision tasks wait until {esc(v['held'])}: no run picks them before then"
|
|
2510
|
+
" (<code>vision_from</code> in <code>config/suite.json</code>; remove it to start them now).</p>"
|
|
2511
|
+
if v["held"] else "")
|
|
2512
|
+
return ("<div class='panel'><h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures with"
|
|
2513
|
+
" a known answer, then real work on images and how much of it was actually checked.</p>" + held +
|
|
2514
|
+
"<h3 class='chart-head'>On the vision tasks, field by field</h3><div class='chart' id='chart-vision-fields'></div>"
|
|
2515
|
+
"<h3 class='chart-head'>On real work with images, by how each seat was judged</h3>"
|
|
2516
|
+
"<div class='chart' id='chart-vision-work'></div>"
|
|
2517
|
+
"<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
|
|
2518
|
+
" data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
|
|
2519
|
+
"<label><input type='checkbox' data-retired> Show retired</label></div>"
|
|
2520
|
+
f"<div class='scroll'>{''.join(body)}</div></details>"
|
|
2521
|
+
f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
|
|
2522
|
+
|
|
2523
|
+
|
|
2218
2524
|
def perf_charts() -> dict:
|
|
2219
2525
|
"""Reliability over the last 14 days of probes, and speed on real tasks
|
|
2220
2526
|
against how much a model finds, for the Performance panel of Benchmarks."""
|
|
@@ -2239,8 +2545,7 @@ def perf_charts() -> dict:
|
|
|
2239
2545
|
|
|
2240
2546
|
def bughunt_card() -> str:
|
|
2241
2547
|
"""Seeded and real-bug tasks against their answer keys."""
|
|
2242
|
-
|
|
2243
|
-
rows = {m: r["hunt"] for m, r in wrong.table().items() if m in current() and any(r["hunt"].values())}
|
|
2548
|
+
rows = {m: r["hunt"] for m, r in wrong_table().items() if m in current() and any(r["hunt"].values())}
|
|
2244
2549
|
body = ["<table><thead><tr>", th("Model"), th("Found", True), th("Wrong line", True), th("Missed", True),
|
|
2245
2550
|
th("Clean said clean", True), th("False alarm", True), "</tr></thead><tbody>"]
|
|
2246
2551
|
for m in sorted(rows, key=lambda m: -rows[m]["found"]):
|
|
@@ -2342,6 +2647,8 @@ def suite_card() -> str:
|
|
|
2342
2647
|
|
|
2343
2648
|
|
|
2344
2649
|
EXTERNAL = (("LiveBench", "https://livebench.ai/", "contamination-limited questions, refreshed monthly"),
|
|
2650
|
+
("FreeLLMAPI", "https://freellmapi.co/models",
|
|
2651
|
+
"every free tier: limits, ranks and quirks per model (Discover leads with it)"),
|
|
2345
2652
|
("models.dev", "https://models.dev/", "specs and prices per provider (the catalog reads it daily)"),
|
|
2346
2653
|
("BullshitBench", "https://petergpt.github.io/bullshit-benchmark/viewer/index.next.html",
|
|
2347
2654
|
"pushing back on nonsense; the idea behind confidently wrong"))
|
|
@@ -2360,8 +2667,9 @@ def external_card() -> str:
|
|
|
2360
2667
|
def wrong_card() -> str:
|
|
2361
2668
|
"""metrics/wrong.py: how often each model asserts something false."""
|
|
2362
2669
|
import wrong
|
|
2363
|
-
|
|
2364
|
-
|
|
2670
|
+
from run import images_only
|
|
2671
|
+
rows = wrong_table()
|
|
2672
|
+
live, images = current(), images_only()
|
|
2365
2673
|
body = ["<table><thead><tr>", th("Model"), th("Confidently wrong", True),
|
|
2366
2674
|
*[th(k.capitalize(), True) for k in wrong.KINDS], th("Reviews", True), "</tr></thead><tbody>"]
|
|
2367
2675
|
|
|
@@ -2378,7 +2686,8 @@ def wrong_card() -> str:
|
|
|
2378
2686
|
f" style='width:{max(4, round(rate * 100))}%'></span></span>"
|
|
2379
2687
|
f"<span class='mono'>{r['wrong']}/{r['answers']}</span></span>")
|
|
2380
2688
|
else:
|
|
2381
|
-
rate, meter = -1, "<span class='muted'>no tasks
|
|
2689
|
+
rate, meter = -1, ("<span class='muted'>reads images: no code tasks</span>" if alias in images
|
|
2690
|
+
else "<span class='muted'>no tasks yet</span>")
|
|
2382
2691
|
body.append(f"<tr><td>{named(alias)}</td><td data-sort='{rate:.3f}'>{meter}</td>"
|
|
2383
2692
|
+ "".join(cell(*r["by"][k]) for k in wrong.KINDS)
|
|
2384
2693
|
+ (f"<td class='num' data-sort='{r['review_false']}'>{r['review_false']} in {r['reviews']}</td>"
|
|
@@ -2404,7 +2713,8 @@ def wrong_card() -> str:
|
|
|
2404
2713
|
|
|
2405
2714
|
def benchmarks_tab() -> str:
|
|
2406
2715
|
data = json.dumps(charts_data(), ensure_ascii=False).replace("</", "<\\/")
|
|
2407
|
-
return (overview_card() + bughunt_card() + work_card() + wrong_card() + suite_card() +
|
|
2716
|
+
return (overview_card() + bughunt_card() + work_card() + wrong_card() + suite_card() + vision_card()
|
|
2717
|
+
+ perf_card() + external_card()
|
|
2408
2718
|
+ f"<script type='application/json' id='charts-data'>{data}</script>")
|
|
2409
2719
|
|
|
2410
2720
|
|
|
@@ -2537,7 +2847,7 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2537
2847
|
judged = {}
|
|
2538
2848
|
owed = seats_total = 0
|
|
2539
2849
|
body = ["<table><thead><tr>", th("When"), th("Collection"), th("Asked by"), th("Explorer shape"),
|
|
2540
|
-
th("Seats"), th("
|
|
2850
|
+
th("Seats"), th("Time", True), "</tr></thead><tbody>"]
|
|
2541
2851
|
for r in records:
|
|
2542
2852
|
chips, row_owed = [], 0
|
|
2543
2853
|
for seat in r.get("seats", []):
|
|
@@ -2557,13 +2867,20 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2557
2867
|
chips += [f"<span class='chip muted' title='{esc('not asked: ' + str(k.get('why', '')))}'>"
|
|
2558
2868
|
f"{esc(k.get('alias', ''))} –</span>" for k in r.get("skipped", []) if isinstance(k, dict)]
|
|
2559
2869
|
owed += row_owed
|
|
2870
|
+
# The request's own time: its seats ran at once, so the slowest of
|
|
2871
|
+
# them, then the merge (a merger seat answers after the rest).
|
|
2872
|
+
secs = [(s.get("seat"), float(s.get("seconds") or 0)) for s in r.get("seats", [])]
|
|
2873
|
+
panel = max((t for seat, t in secs if seat != "merger"), default=0.0)
|
|
2874
|
+
merge = sum(t for seat, t in secs if seat == "merger")
|
|
2875
|
+
took = (f"<td class='num' data-sort='{panel + merge:.1f}' title='slowest seat {panel:.1f}s"
|
|
2876
|
+
f"{f' + merge {merge:.1f}s' if merge else ''}'>{panel + merge:.0f}s</td>" if secs
|
|
2877
|
+
else "<td class='num muted' data-sort='-1'>—</td>")
|
|
2560
2878
|
body.append(
|
|
2561
2879
|
f"<tr><td class='mono' data-sort='{esc(r.get('at', ''))}'>{esc(r.get('at', '')[:16].replace('T', ' '))}</td>"
|
|
2562
2880
|
f"<td class='mono'>{esc(r.get('model', ''))}</td>"
|
|
2563
2881
|
f"<td class='mono'>{esc(r.get('key') or '—')}</td>"
|
|
2564
2882
|
f"<td title='{esc(r.get('shape_by', ''))}'>{esc(r.get('shape', ''))}</td>"
|
|
2565
|
-
f"<td>{' '.join(chips)}</td>"
|
|
2566
|
-
f"<td class='num{' muted' if not row_owed else ''}'>{row_owed}</td></tr>")
|
|
2883
|
+
f"<td>{' '.join(chips)}</td>{took}</tr>")
|
|
2567
2884
|
if not records:
|
|
2568
2885
|
body.append("<tr><td colspan='6' class='muted'>No gateway requests yet. Call a collection"
|
|
2569
2886
|
f" at <code>{esc(gateway_url())}</code>.</td></tr>")
|
|
@@ -2578,7 +2895,10 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2578
2895
|
carried none. Hover it for why.</dd>
|
|
2579
2896
|
<dt>Asked by</dt><dd>The gateway key that made the request, or <code>local</code>
|
|
2580
2897
|
when no key is asked for.</dd>
|
|
2581
|
-
<dt>
|
|
2898
|
+
<dt>Time</dt><dd>How long the caller waited: the seats run at once, so the
|
|
2899
|
+
slowest of them, plus the merge when the reply was merged. Hover for the two
|
|
2900
|
+
parts; a chip's own time is on the chip.</dd>
|
|
2901
|
+
<dt>Verdicts owed</dt><dd>A chip with no mark. Each is recorded as an unjudged
|
|
2582
2902
|
offload, so rotation still counts it; a verdict tells the router whether
|
|
2583
2903
|
it helped. The orchestrator that called can also record one:
|
|
2584
2904
|
<code>python metrics/offload.py verdict <call id> --useful</code>.</dd>
|
|
@@ -2717,7 +3037,7 @@ def discover_model(provider: str, model: str) -> dict:
|
|
|
2717
3037
|
|
|
2718
3038
|
|
|
2719
3039
|
def discover_card() -> str:
|
|
2720
|
-
"""Models worth adding, from models.dev
|
|
3040
|
+
"""Models worth adding, from FreeLLMAPI and models.dev (metrics/discover.py),
|
|
2721
3041
|
deduped. Read from the daily cache, so the page never waits on the network;
|
|
2722
3042
|
built once per cache refresh. A click on a row opens its popup (bench.js),
|
|
2723
3043
|
which asks /api/discover-model for the rest: its routes are where Add is."""
|
|
@@ -2725,7 +3045,8 @@ def discover_card() -> str:
|
|
|
2725
3045
|
rows = [r for r in all_rows if r["free"] or r["reach"] in ("connected", "on bench")]
|
|
2726
3046
|
hidden = len(all_rows) - len(rows)
|
|
2727
3047
|
reach_css = {"on bench": "ok", "connected": "ok", "easy to add": "warn", "other": ""}
|
|
2728
|
-
body = ["<table><thead><tr>", th("Model"), th("
|
|
3048
|
+
body = ["<table><thead><tr>", th("Model"), th("Rank", True), th("Provider"), th("Reach"), th("Price"),
|
|
3049
|
+
th("Context", True),
|
|
2729
3050
|
th("In → out"), th("Can"), th("Released"), th("Limits"), "<th></th></tr></thead><tbody>"]
|
|
2730
3051
|
for r in rows:
|
|
2731
3052
|
caps = [c for c, on in (("tools", r["tools"]), ("vision", r["vision"]),
|
|
@@ -2734,8 +3055,13 @@ def discover_card() -> str:
|
|
|
2734
3055
|
price = ("free" if r["free"] else
|
|
2735
3056
|
f"{cost.get('input', '?')} / {cost.get('output', '?')}" if cost else "paid")
|
|
2736
3057
|
lim = r.get("limits") or {}
|
|
2737
|
-
lim_text = " · ".join(f"{v:,} {k}" for k, v in lim.items()
|
|
3058
|
+
lim_text = " · ".join([f"{v:,} {k}" for k, v in lim.items()]
|
|
3059
|
+
+ ([f"{r['budget']} tokens a month"] if r.get("budget") else []))
|
|
2738
3060
|
ctx = r.get("context")
|
|
3061
|
+
rank = (f"<td class='num' data-sort='{r['rank']}' title='FreeLLMAPI: smartest #{r['rank']}"
|
|
3062
|
+
+ (f", fastest #{r['speed']}" if r.get("speed") else "")
|
|
3063
|
+
+ (f", {esc(r['size'])}" if r.get("size") else "") + f"'>{r['rank']}</td>"
|
|
3064
|
+
if r.get("rank") is not None else "<td class='num muted' data-sort='99999'>—</td>")
|
|
2739
3065
|
add = ("<button class='btn small' type='button'>Add…</button>"
|
|
2740
3066
|
if any(not x["on_bench_as"] for x in r["routes"]) else "")
|
|
2741
3067
|
body.append(
|
|
@@ -2746,8 +3072,8 @@ def discover_card() -> str:
|
|
|
2746
3072
|
+ f"<span class='conn'>{esc(r['model'])}"
|
|
2747
3073
|
+ (f" · on bench as {esc(r['on_bench_as'])}" if r.get("on_bench_as") else "")
|
|
2748
3074
|
+ (f" · also on bench as {esc(', '.join(r['also_on']))}" if r.get("also_on") else "")
|
|
2749
|
-
+ "</span></td>"
|
|
2750
|
-
f"<td title='{esc(r['provider'])}'>{esc(r['provider_name'])}</td>"
|
|
3075
|
+
+ "</span></td>" + rank
|
|
3076
|
+
+ f"<td title='{esc(r['provider'])}'>{esc(r['provider_name'])}</td>"
|
|
2751
3077
|
f"<td data-sort='{['on bench', 'connected', 'easy to add', 'other'].index(r['reach'])}'>"
|
|
2752
3078
|
f"{chip(r['reach'], reach_css[r['reach']], r.get('api') or '')}</td>"
|
|
2753
3079
|
f"<td title='per million tokens, input / output'>{esc(price)}</td>"
|
|
@@ -2764,31 +3090,40 @@ def discover_card() -> str:
|
|
|
2764
3090
|
" provider or reach' aria-label='Filter rows'>"
|
|
2765
3091
|
"<label><input type='checkbox' data-paid> Show paid (connected providers)</label></div>")
|
|
2766
3092
|
key = """<dl>
|
|
3093
|
+
<dt>Rank</dt><dd>FreeLLMAPI's smartest-first order (1 is the smartest it lists); within
|
|
3094
|
+
each reach the list follows it. Hover for its fastest-first rank and size. A dash where
|
|
3095
|
+
only models.dev lists the model.</dd>
|
|
2767
3096
|
<dt>Provider</dt><dd>Who serves the model: a row per provider, so the same model
|
|
2768
|
-
from OpenRouter and from NVIDIA is two rows. Hover for its
|
|
3097
|
+
from OpenRouter and from NVIDIA is two rows. Hover for its id.</dd>
|
|
2769
3098
|
<dt>Reach</dt><dd><em>on bench</em> already registered; <em>connected</em> this
|
|
2770
3099
|
install has a route to the provider (an Open WebUI connection, or an endpoint of its
|
|
2771
3100
|
own); <em>easy to add</em> an OpenAI-compatible API, so one new connection away (hover
|
|
2772
3101
|
for its base URL); <em>other</em> a native API or an unvetted reseller.</dd>
|
|
2773
|
-
<dt>Price</dt><dd>Free if
|
|
2774
|
-
|
|
3102
|
+
<dt>Price</dt><dd>Free if FreeLLMAPI lists it (a catalog of free tiers) or models.dev
|
|
3103
|
+
prices it at 0: models.dev shows the PAID price even where a free tier exists. Otherwise
|
|
2775
3104
|
input / output per million tokens.</dd>
|
|
3105
|
+
<dt>Limits</dt><dd>The free tier's, from FreeLLMAPI: requests and tokens per minute (rpm,
|
|
3106
|
+
tpm) and per day (rpd, tpd), and roughly how many tokens a month that allows.</dd>
|
|
2776
3107
|
<dt>New</dt><dd>First seen in either list in the last 7 days.</dd>
|
|
2777
3108
|
<dt>Add</dt><dd>Click a row: its popup has everything known about the model and every
|
|
2778
3109
|
route this install has to it, each with <em>Add via this</em>. <em>Via Open WebUI</em>
|
|
2779
3110
|
adds the model to that connection's Model IDs (when it lists them), creates its model
|
|
2780
3111
|
record public, tagged like the connection's other models and with their logo, then runs
|
|
2781
|
-
the bench's onboarding (
|
|
3112
|
+
the bench's onboarding as the confirm chooses (Onboard as, Vision model), so it is in
|
|
3113
|
+
the chats too.
|
|
2782
3114
|
<em>Via LiteLLM</em> or <em>CLI Proxy API</em> adds it to that hub's connection (one more
|
|
2783
3115
|
deployment, or the provider's models list); those hubs are asked when the popup opens.
|
|
2784
3116
|
<em>Direct</em> onboards it on the provider's own endpoint: bench only. A new provider
|
|
2785
3117
|
needs a connection or an endpoint first; hover its <em>easy to add</em> chip for the
|
|
2786
3118
|
base URL.</dd>
|
|
2787
|
-
<dt>Sources</dt><dd>
|
|
2788
|
-
|
|
3119
|
+
<dt>Sources</dt><dd>FreeLLMAPI's free catalog first: where it lists a model, its name,
|
|
3120
|
+
provider, context, tools, limits, ranks and quirks lead, and the popup links its
|
|
3121
|
+
freellmapi.co page. models.dev fills the rest and alone has the paid models, prices,
|
|
3122
|
+
base URLs and modalities. A model one provider offers in both is shown once. Both are
|
|
3123
|
+
refreshed at most daily.</dd>
|
|
2789
3124
|
</dl>"""
|
|
2790
|
-
return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from
|
|
2791
|
-
f" and
|
|
3125
|
+
return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from FreeLLMAPI"
|
|
3126
|
+
f" and models.dev, deduped; {hidden:,} paid models on providers you have no route"
|
|
2792
3127
|
f" to are left out. Click a model for everything known about it and the routes it can be"
|
|
2793
3128
|
f" added by.</p>{toolbar}"
|
|
2794
3129
|
f"<div class='scroll'>{''.join(body)}</div>"
|
|
@@ -2832,8 +3167,11 @@ def collections_tab() -> str:
|
|
|
2832
3167
|
# Not data-filter: that binds to the whole panel. The same match (any
|
|
2833
3168
|
# text, case-insensitive), on the member list only; ticked ones stay shown.
|
|
2834
3169
|
"<label class='field' for='c-member-filter'>Members</label>"
|
|
3170
|
+
# The panel as it will sit, drawn as the Collections list draws it
|
|
3171
|
+
# (bench.js recount): a seat per ticked member, explorer seats dashed.
|
|
3172
|
+
"<div id='c-seats' class='seats' aria-live='polite'></div>"
|
|
2835
3173
|
"<input type='search' id='c-member-filter' class='member-filter' spellcheck='false' autocomplete='off'"
|
|
2836
|
-
" placeholder='Filter by
|
|
3174
|
+
" placeholder='Filter by model, provider or served name' aria-controls='c-members' aria-describedby='c-count'>"
|
|
2837
3175
|
"<div id='c-members' class='members' role='group' aria-label='Members'></div>"
|
|
2838
3176
|
"<p class='rules' id='c-count'></p>"
|
|
2839
3177
|
"<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
|
|
@@ -3044,12 +3382,71 @@ def access_card() -> str:
|
|
|
3044
3382
|
body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td>{esc(v)}</td>"
|
|
3045
3383
|
f"<td class='mono set-by'>{'<br>'.join(esc(s) for s in src.split(', '))}</td></tr>"
|
|
3046
3384
|
for k, v, src in rows)
|
|
3385
|
+
running, disk = on_disk()
|
|
3386
|
+
now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
|
|
3387
|
+
f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
|
|
3047
3388
|
return ("<div class='panel'><h2>Server and access</h2><p class='muted'>How this install is reached and who"
|
|
3048
3389
|
" may call it. Set in the environment, read at start: change one and restart. Shown here, never"
|
|
3049
3390
|
" set here, since a page that could change its own login could open itself to anyone. No secret"
|
|
3050
3391
|
" is shown, only whether one is set.</p>"
|
|
3051
3392
|
f"<table class='facts'><thead><tr>{th('Setting')}{th('Now')}{th('Set by')}</tr></thead>"
|
|
3052
|
-
f"<tbody>{body}</tbody></table
|
|
3393
|
+
f"<tbody>{body}</tbody></table>"
|
|
3394
|
+
# restart(): stopped and started again by serve.py's own parent process.
|
|
3395
|
+
f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
|
|
3396
|
+
" for an update or a setting"
|
|
3397
|
+
" above, whatever runs it (a service, the launcher, a container or a terminal). It waits for"
|
|
3398
|
+
" onboarding, a scheduled job and gateway calls in progress, or cuts them off if you insist; the"
|
|
3399
|
+
" page comes back in a few seconds.</p>"
|
|
3400
|
+
"<p><button class='btn' type='button' id='restart'>Restart Benchwarmers</button>"
|
|
3401
|
+
" <span id='restart-out' role='status'></span></p></div>")
|
|
3402
|
+
|
|
3403
|
+
|
|
3404
|
+
RESTART = 75 # the exit code serve.py's parent reads as "start me again"
|
|
3405
|
+
SERVER = None # the running ThreadingHTTPServer, set in __main__
|
|
3406
|
+
_answering = [0] # gateway calls being answered now
|
|
3407
|
+
_answering_lock = threading.Lock()
|
|
3408
|
+
|
|
3409
|
+
|
|
3410
|
+
def _follow_parent() -> None:
|
|
3411
|
+
"""End with serve.py's parent however it went (a service stopped, a
|
|
3412
|
+
terminal closed), so no orphan keeps the port. Its process is waited on,
|
|
3413
|
+
not a pipe read: on Windows a thread blocked reading stdin stalls every
|
|
3414
|
+
subprocess started meanwhile (git, the onboarding, the jobs)."""
|
|
3415
|
+
ppid = int(os.environ.get("BENCH_PARENT") or 0)
|
|
3416
|
+
if not ppid:
|
|
3417
|
+
return
|
|
3418
|
+
if os.name == "nt":
|
|
3419
|
+
import ctypes
|
|
3420
|
+
k32 = ctypes.windll.kernel32
|
|
3421
|
+
handle = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
|
|
3422
|
+
if not handle:
|
|
3423
|
+
return # cannot watch it: stay up
|
|
3424
|
+
k32.WaitForSingleObject(handle, 0xFFFFFFFF) # until it ends
|
|
3425
|
+
else:
|
|
3426
|
+
while os.getppid() == ppid: # a new parent: the old one ended
|
|
3427
|
+
time.sleep(2)
|
|
3428
|
+
os._exit(0)
|
|
3429
|
+
|
|
3430
|
+
|
|
3431
|
+
def restart(force: bool = False, who: str = "ui") -> dict:
|
|
3432
|
+
"""End this dashboard and start it again on the code now on disk: after
|
|
3433
|
+
an update, or a setting read at start. serve.py runs it as a child of its
|
|
3434
|
+
own parent process (see __main__), which starts it again, so this works
|
|
3435
|
+
however serve.py was started: Servy, the npm or pip launcher, Docker or a
|
|
3436
|
+
terminal. Refused while something would be cut off, unless `force`."""
|
|
3437
|
+
import scheduler
|
|
3438
|
+
if os.environ.get("BENCH_SERVED") != "1" or SERVER is None:
|
|
3439
|
+
return {"ok": False, "errors": ["this dashboard was not started by serve.py's own parent process,"
|
|
3440
|
+
" so nothing would start it again: restart it the way you started it"]}
|
|
3441
|
+
busy = ([f"onboarding is running ({sum(i['state'] in ('queued', 'running') for i in _OQ['items'])} left):"
|
|
3442
|
+
" Stop it in the queue, or wait"] if _OQ["worker"] is not None else []) \
|
|
3443
|
+
+ ([f"a scheduled job is running: {', '.join(scheduler.RUNNING)}"] if scheduler.RUNNING else []) \
|
|
3444
|
+
+ ([f"{_answering[0]} gateway call(s) are being answered"] if _answering[0] else [])
|
|
3445
|
+
if busy and not force:
|
|
3446
|
+
return {"ok": False, "busy": True, "errors": busy}
|
|
3447
|
+
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "", "restart", *on_disk())
|
|
3448
|
+
threading.Timer(0.5, SERVER.shutdown).start() # after this reply has gone
|
|
3449
|
+
return {"ok": True, "errors": [], "cut": busy}
|
|
3053
3450
|
|
|
3054
3451
|
|
|
3055
3452
|
def save_scheduler(mode: str, jobs, who: str = "ui") -> dict:
|
|
@@ -3340,27 +3737,39 @@ def models_page() -> str:
|
|
|
3340
3737
|
+ "".join(cards))
|
|
3341
3738
|
|
|
3342
3739
|
|
|
3740
|
+
def quota_tiles() -> list[dict]:
|
|
3741
|
+
"""metrics/quotaview.py's tiles, each named as the collection popup names
|
|
3742
|
+
its provider, unless the live read named the credentials behind it."""
|
|
3743
|
+
import quotaview
|
|
3744
|
+
return [dict(t, name=t["name"] or provider_label(t["tag"])) for t in quotaview.tiles()]
|
|
3745
|
+
|
|
3746
|
+
|
|
3747
|
+
def quota_op(which: str, tag) -> dict:
|
|
3748
|
+
"""Refresh or Check now on one tile: {ok, errors, note, tile}; "order"
|
|
3749
|
+
saves the tiles' order (a list of tags, empty for the default sort)."""
|
|
3750
|
+
import quotaview
|
|
3751
|
+
if which == "order":
|
|
3752
|
+
return quotaview.save_order(tag)
|
|
3753
|
+
if tag not in {t["id"] for t in quota_tiles()}:
|
|
3754
|
+
return {"ok": False, "errors": [f"no tile called {tag!r}"]}
|
|
3755
|
+
try:
|
|
3756
|
+
r = (quotaview.refresh if which == "refresh" else quotaview.check)(tag)
|
|
3757
|
+
except (ValueError, OSError) as exc: # OSError: URLError and timeouts
|
|
3758
|
+
return {"ok": False, "errors": [str(exc)]}
|
|
3759
|
+
if not r.get("ok"):
|
|
3760
|
+
return {"ok": False, "errors": r.get("errors") or ["it failed"]}
|
|
3761
|
+
return {"ok": True, "errors": [], "note": r.get("note", ""),
|
|
3762
|
+
"tile": next((t for t in quota_tiles() if t["id"] == tag), None)}
|
|
3763
|
+
|
|
3764
|
+
|
|
3343
3765
|
def quotas_page() -> str:
|
|
3344
|
-
"""
|
|
3345
|
-
|
|
3346
|
-
|
|
3766
|
+
"""A tile per provider: what is left and when it resets, shaped by how the
|
|
3767
|
+
provider limits (metrics/quotaview.py), who is resting, Refresh and Check
|
|
3768
|
+
now; the resets of the next week; what the bench sent (its usage ledger,
|
|
3769
|
+
metrics/ledger.py); every limit known. bench.js draws tiles and charts."""
|
|
3347
3770
|
import learn
|
|
3348
3771
|
import ledger
|
|
3349
|
-
import quota
|
|
3350
3772
|
now = datetime.datetime.now().astimezone()
|
|
3351
|
-
rec_rows = []
|
|
3352
|
-
for r in sorted(quota.live(), key=lambda r: (r.get("connection", ""), r.get("alias", ""), r.get("metric", ""))):
|
|
3353
|
-
try:
|
|
3354
|
-
age = int((now - datetime.datetime.fromisoformat(r["observed_at"])).total_seconds() // 60)
|
|
3355
|
-
reset = datetime.datetime.fromisoformat(r["reset_at"]).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
3356
|
-
except (KeyError, TypeError, ValueError):
|
|
3357
|
-
continue
|
|
3358
|
-
num = lambda v: f"{v:,}" if isinstance(v, int) else "—" # noqa: E731
|
|
3359
|
-
rec_rows.append(f"<tr><td>{esc(r.get('connection', ''))}</td><td>{named(r.get('alias', ''))}</td>"
|
|
3360
|
-
f"<td>{esc(r.get('metric', ''))} / {esc(r.get('window', ''))}</td>"
|
|
3361
|
-
f"<td class='num'>{num(r.get('remaining'))}</td><td class='num'>{num(r.get('limit'))}</td>"
|
|
3362
|
-
f"<td class='mono'>{esc(reset)}</td>"
|
|
3363
|
-
f"<td>{esc(r.get('source', ''))}, {age} min ago</td></tr>")
|
|
3364
3773
|
t = now.timestamp()
|
|
3365
3774
|
sent: dict = {}
|
|
3366
3775
|
for row in ledger.rows(min(t - 86400, ledger.month_start(t))):
|
|
@@ -3372,15 +3781,10 @@ def quotas_page() -> str:
|
|
|
3372
3781
|
s[2], s[3] = s[2] + n, s[3] + tok
|
|
3373
3782
|
sent_rows = [f"<tr><td>{esc(c)}</td>" + "".join(f"<td class='num'>{v:,}</td>" for v in s) + "</tr>"
|
|
3374
3783
|
for c, s in sorted(sent.items())]
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
except (KeyError, ValueError, TypeError):
|
|
3380
|
-
left = 0
|
|
3381
|
-
cool_rows.append(f"<tr><td>{named(alias)}</td><td>{esc(catalog.connection_of(alias))}</td>"
|
|
3382
|
-
f"<td class='num' data-sort='{left}'>{left} min</td>"
|
|
3383
|
-
f"<td>{'stated by the provider' if c.get('stated') else 'default wait'}</td></tr>")
|
|
3784
|
+
data = json.dumps({"tiles": quota_tiles(),
|
|
3785
|
+
"sent": [{"provider": provider_label(c) if c != "?" else "?", "day": s[0], "month": s[2]}
|
|
3786
|
+
for c, s in sent.items() if s[2]]},
|
|
3787
|
+
ensure_ascii=False).replace("</", "<\\/")
|
|
3384
3788
|
entered = catalog.connection_limits()
|
|
3385
3789
|
keys = catalog.CONNECTION_LIMIT_KEYS
|
|
3386
3790
|
lim_rows = [f"<tr><td>{esc(conn)}</td>" + "".join(
|
|
@@ -3395,40 +3799,42 @@ def quotas_page() -> str:
|
|
|
3395
3799
|
+ "".join(f"<td class='num'>{ev['value']:,}</td>" if kk == k
|
|
3396
3800
|
else "<td class='num muted'>—</td>" for kk in keys)
|
|
3397
3801
|
+ f"<td>learned {esc(ev.get('at', '')[:10])} ({esc(ev.get('where', ''))})</td></tr>")
|
|
3802
|
+
sent_table = ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
|
|
3803
|
+
+ th("Requests, this month", True) + th("Tokens, this month", True)
|
|
3804
|
+
+ f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
|
|
3805
|
+
if sent_rows else "<p class='muted'>No call in the ledger this month.</p>")
|
|
3806
|
+
sent_key = ("<p>One row per provider, most requests this month first. The pale bar is this month's"
|
|
3807
|
+
" requests, the dark one inside it the last 24 hours'; the label reads 24 h / month."
|
|
3808
|
+
" Retries count. Calls through the llm CLI are not in the ledger.</p>")
|
|
3398
3809
|
return (
|
|
3399
|
-
"<div class='panel'><h2>Quota
|
|
3400
|
-
"
|
|
3401
|
-
"
|
|
3402
|
-
"
|
|
3403
|
-
"
|
|
3404
|
-
"<div class='
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
"
|
|
3411
|
-
"
|
|
3412
|
-
+ ("
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
"
|
|
3418
|
-
|
|
3419
|
-
+ f"</tr></thead><tbody>{''.join(cool_rows)}</tbody></table>" if cool_rows
|
|
3420
|
-
else "<p class='muted'>None: every model is free to call.</p>")
|
|
3421
|
-
+ "</div>"
|
|
3422
|
-
"<div class='panel'><h3 class='chart-head'>Known limits</h3><p class='sub'>How much each connection"
|
|
3423
|
-
" or model allows (tokens or requests per minute or day), so the bench can space its calls."
|
|
3424
|
-
" Two sources: what you enter per connection (Endpoints, Rate limits per connection), and what a"
|
|
3425
|
-
" provider states in a rate-limit error, which the bench writes down by itself. The list grows on"
|
|
3426
|
-
" its own as models meet their limits. A model's own limits (its popup) win, and learned ones"
|
|
3427
|
-
" only ever lower a limit.</p>"
|
|
3810
|
+
"<div class='panel'><h2>Quota</h2><p class='sub'>What is left with each provider and when it"
|
|
3811
|
+
" resets, and who is resting after a rate limit. CLI Proxy API's credentials and OpenRouter are"
|
|
3812
|
+
" read live by Refresh, which spends nothing; every other tile is what the provider last said"
|
|
3813
|
+
" in its replies to the bench, and Check now, where it is offered, asks with one request."
|
|
3814
|
+
" Drag a tile, or move it with the arrow keys, to keep it where you want it.</p>"
|
|
3815
|
+
"<div class='list-actions'><button class='btn' id='q-all'>Refresh all live</button>"
|
|
3816
|
+
"<button class='btn' id='q-reorder'>Sort: out first, then soonest reset</button>"
|
|
3817
|
+
"<span class='done' id='q-done' aria-live='polite'></span>"
|
|
3818
|
+
"<span class='errors' id='q-errors' aria-live='polite'></span></div>"
|
|
3819
|
+
"<div class='q-tiles' id='q-tiles'></div></div>"
|
|
3820
|
+
"<div class='panel'><h3 class='chart-head'>Resets, next 7 days</h3><p class='sub'>A dot where a"
|
|
3821
|
+
" limit resets (a daily one every day), a bar while a model rests, the line is now.</p>"
|
|
3822
|
+
"<div class='chart' id='chart-resets'></div></div>"
|
|
3823
|
+
+ chart_card("What the bench sent", "Requests per provider: this month, and the last 24 hours inside it.",
|
|
3824
|
+
"chart-sent", sent_table, sent_key, retired=False)
|
|
3825
|
+
+ "<details class='panel'><summary><b>Known limits</b></summary><p class='sub'>How much each"
|
|
3826
|
+
" connection or model allows (tokens or requests per minute or day), so the bench can space its"
|
|
3827
|
+
" calls. Two sources: what you enter per connection (Endpoints, Rate limits per connection), and"
|
|
3828
|
+
" what a provider states in a rate-limit error, which the bench writes down by itself. A model's"
|
|
3829
|
+
" own limits (its popup) win, and learned ones only ever lower a limit.</p>"
|
|
3428
3830
|
+ ("<table><thead><tr>" + th("Connection or model") + "".join(th(k, True) for k in keys) + th("From")
|
|
3429
3831
|
+ f"</tr></thead><tbody>{''.join(lim_rows)}</tbody></table>" if lim_rows
|
|
3430
3832
|
else "<p class='muted'>None known yet.</p>")
|
|
3431
|
-
+ "</
|
|
3833
|
+
+ "</details>"
|
|
3834
|
+
"<dialog id='q-models' aria-labelledby='q-models-h'><h3 id='q-models-h'></h3>"
|
|
3835
|
+
"<p class='sub' id='q-models-sub'></p><div class='scroll' id='q-models-list'></div>"
|
|
3836
|
+
"<div class='list-actions'><button class='btn' id='q-models-close'>Close</button></div></dialog>"
|
|
3837
|
+
f"<script type='application/json' id='quota-data'>{data}</script>")
|
|
3432
3838
|
|
|
3433
3839
|
|
|
3434
3840
|
def _log_rows(path: pathlib.Path, limit: int = 200) -> list[str]:
|
|
@@ -3500,6 +3906,14 @@ RELEASE, RELEASE_DATE, COMMIT = version()
|
|
|
3500
3906
|
VERSION = f"{RELEASE} {RELEASE_DATE}".strip() # the X-Benchwarmers-Version header
|
|
3501
3907
|
|
|
3502
3908
|
|
|
3909
|
+
def on_disk() -> tuple[str, str]:
|
|
3910
|
+
"""(running, on disk): what this process started from and what a restart
|
|
3911
|
+
would start, by commit, or by the VERSION file where there is no git."""
|
|
3912
|
+
if COMMIT:
|
|
3913
|
+
return COMMIT, _git("log", "-1", "--format=%h") or COMMIT
|
|
3914
|
+
return RELEASE, read(ROOT / "VERSION").strip()
|
|
3915
|
+
|
|
3916
|
+
|
|
3503
3917
|
def info_page() -> str:
|
|
3504
3918
|
import scheduler
|
|
3505
3919
|
import client as model_client
|
|
@@ -3608,6 +4022,7 @@ def editor() -> str:
|
|
|
3608
4022
|
# The page's own confirm (bench.js ask): window.confirm can be blocked.
|
|
3609
4023
|
"<dialog id='ask-dialog' aria-labelledby='ask-text'>"
|
|
3610
4024
|
"<p id='ask-text' style='white-space:pre-line'></p>"
|
|
4025
|
+
f"<div id='ask-onboard' hidden>{onboard_choice('ask')}</div>" # Discover's Add only
|
|
3611
4026
|
"<div class='actions'><button class='btn' id='ask-no' type='button'>Cancel</button>"
|
|
3612
4027
|
"<button class='btn primary' id='ask-yes' type='button'>OK</button></div></dialog>"
|
|
3613
4028
|
# onboarding
|
|
@@ -3620,13 +4035,12 @@ def editor() -> str:
|
|
|
3620
4035
|
" with progress in the header. A model from somewhere else? Add its endpoint first.</p>"
|
|
3621
4036
|
"<input type='search' id='o-find' placeholder='Search models or connections'"
|
|
3622
4037
|
" aria-label='Search models'>"
|
|
4038
|
+
# How old the list is (serve.LISTED_FOR), and asking again now.
|
|
4039
|
+
"<p class='muted' id='o-age' hidden><span></span>"
|
|
4040
|
+
" <button class='linkish' type='button' id='o-refresh'>Refresh</button></p>"
|
|
3623
4041
|
"<div class='picker' id='o-picker' role='group' aria-label='Served models'></div>"
|
|
3624
4042
|
# serve.ONBOARD_MODES: how much each onboarding spends.
|
|
3625
|
-
"
|
|
3626
|
-
"<option value='full'>Full: probe, then the suite now</option>"
|
|
3627
|
-
"<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
|
|
3628
|
-
"<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
|
|
3629
|
-
" (metered, capped or trial keys)</option></select></label>"
|
|
4043
|
+
f"{onboard_choice('o')}"
|
|
3630
4044
|
"</div>"
|
|
3631
4045
|
"<div id='o-run' hidden><p class='served' id='o-result'></p><ol class='steps' id='o-steps'></ol>"
|
|
3632
4046
|
"<details><summary class='muted'>Full log</summary><pre class='log' id='o-log'></pre></details></div>"
|
|
@@ -3761,8 +4175,8 @@ class Handler(gateway.Handler):
|
|
|
3761
4175
|
log = parse_qs(urlparse(self.path).query).get("log", [""])[0]
|
|
3762
4176
|
self._json(200, onboard_status(log))
|
|
3763
4177
|
return
|
|
3764
|
-
if self.path
|
|
3765
|
-
self._json(200, onboard_candidates())
|
|
4178
|
+
if self.path in ("/api/candidates", "/api/candidates?fresh=1"):
|
|
4179
|
+
self._json(200, onboard_candidates(fresh=self.path.endswith("fresh=1")))
|
|
3766
4180
|
return
|
|
3767
4181
|
if self.path == "/api/onboard-queue":
|
|
3768
4182
|
self._json(200, onboard_queue())
|
|
@@ -3866,7 +4280,13 @@ class Handler(gateway.Handler):
|
|
|
3866
4280
|
"""
|
|
3867
4281
|
path = self.path.split("?", 1)[0]
|
|
3868
4282
|
if guard.gateway_route(path):
|
|
3869
|
-
|
|
4283
|
+
with _answering_lock:
|
|
4284
|
+
_answering[0] += 1 # restart() waits for these
|
|
4285
|
+
try:
|
|
4286
|
+
return super().do_POST()
|
|
4287
|
+
finally:
|
|
4288
|
+
with _answering_lock:
|
|
4289
|
+
_answering[0] -= 1
|
|
3870
4290
|
if path == "/login":
|
|
3871
4291
|
return self._login()
|
|
3872
4292
|
ops = {"/api/model": lambda p: change_model(str(p.get("alias", "")),
|
|
@@ -3878,10 +4298,13 @@ class Handler(gateway.Handler):
|
|
|
3878
4298
|
"/api/retry": lambda p: retry_model(str(p.get("alias") or "")),
|
|
3879
4299
|
"/api/discover/add": lambda p: discover_add(str(p.get("connection") or ""),
|
|
3880
4300
|
str(p.get("model") or ""),
|
|
3881
|
-
endpoint=str(p.get("endpoint") or "")
|
|
4301
|
+
endpoint=str(p.get("endpoint") or ""),
|
|
4302
|
+
mode=str(p.get("mode") or "full"),
|
|
4303
|
+
vision=p.get("vision") is True),
|
|
3882
4304
|
"/api/onboard": lambda p: start_onboard(str(p.get("served", "")),
|
|
3883
4305
|
str(p.get("endpoint", "")),
|
|
3884
|
-
mode=str(p.get("mode") or "full")
|
|
4306
|
+
mode=str(p.get("mode") or "full"),
|
|
4307
|
+
vision=p.get("vision") is True),
|
|
3885
4308
|
"/api/onboard-queue": lambda p: queue_onboard(p.get("items")),
|
|
3886
4309
|
"/api/onboard-queue/stop": lambda p: stop_onboard_queue(),
|
|
3887
4310
|
"/api/onboard-queue/clear": lambda p: clear_onboard_queue(),
|
|
@@ -3922,7 +4345,11 @@ class Handler(gateway.Handler):
|
|
|
3922
4345
|
"/api/gateway-key": lambda p: make_gateway_key(str(p.get("name") or ""), p.get("collections"),
|
|
3923
4346
|
p.get("rpm")),
|
|
3924
4347
|
"/api/gateway-key/remove": lambda p: revoke_gateway_key(str(p.get("name") or "")),
|
|
3925
|
-
"/api/theme": lambda p: save_theme(str(p.get("theme") or ""))
|
|
4348
|
+
"/api/theme": lambda p: save_theme(str(p.get("theme") or "")),
|
|
4349
|
+
"/api/quota/refresh": lambda p: quota_op("refresh", str(p.get("tag") or "")),
|
|
4350
|
+
"/api/quota/check": lambda p: quota_op("check", str(p.get("tag") or "")),
|
|
4351
|
+
"/api/quota/order": lambda p: quota_op("order", p.get("order")),
|
|
4352
|
+
"/api/restart": lambda p: restart(p.get("force") is True)}
|
|
3926
4353
|
# SAME ORIGIN ONLY. Any page open in the browser can POST a plain form
|
|
3927
4354
|
# to 127.0.0.1, and this used to parse whatever arrived as JSON -- so a
|
|
3928
4355
|
# website could retire models, and now could set a key. The checks are
|
|
@@ -4019,6 +4446,19 @@ class Handler(gateway.Handler):
|
|
|
4019
4446
|
|
|
4020
4447
|
|
|
4021
4448
|
if __name__ == "__main__":
|
|
4449
|
+
if os.environ.get("BENCH_SERVED") != "1":
|
|
4450
|
+
# The dashboard runs as this process's child, so Restart (restart())
|
|
4451
|
+
# can end it and have it started again on the code now on disk,
|
|
4452
|
+
# whatever started serve.py. The child ends with this one (_follow_parent).
|
|
4453
|
+
env = {**os.environ, "BENCH_SERVED": "1", "BENCH_PARENT": str(os.getpid())}
|
|
4454
|
+
try:
|
|
4455
|
+
while (code := subprocess.call([sys.executable, os.path.abspath(__file__), *sys.argv[1:]],
|
|
4456
|
+
env=env, stdin=subprocess.DEVNULL)) == RESTART:
|
|
4457
|
+
print("Benchwarmers: restarting on the code now on disk", flush=True)
|
|
4458
|
+
except KeyboardInterrupt:
|
|
4459
|
+
code = 0
|
|
4460
|
+
sys.exit(code)
|
|
4461
|
+
threading.Thread(target=_follow_parent, daemon=True).start()
|
|
4022
4462
|
address = guard.bind()
|
|
4023
4463
|
guard.startup(address) # beyond this machine, only with an admin token
|
|
4024
4464
|
shown = "localhost" if address == "127.0.0.1" else address
|
|
@@ -4027,4 +4467,6 @@ if __name__ == "__main__":
|
|
|
4027
4467
|
paths.make() # a fresh data folder's layout, before any writer
|
|
4028
4468
|
import scheduler
|
|
4029
4469
|
scheduler.start() # off unless config/scheduler.json says otherwise
|
|
4030
|
-
ThreadingHTTPServer((address, PORT), Handler)
|
|
4470
|
+
SERVER = ThreadingHTTPServer((address, PORT), Handler)
|
|
4471
|
+
SERVER.serve_forever() # returns only when restart() shuts it down
|
|
4472
|
+
sys.exit(RESTART)
|