@wendellresearch-ai/benchwarmers 0.4.0 → 0.5.1
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 +16 -3
- package/README.md +20 -8
- package/USAGE.md +105 -14
- package/VERSION +1 -1
- package/cli.py +13 -4
- package/gateway.py +34 -2
- package/launcher.py +2 -1
- package/metrics/catalog.py +5 -0
- package/metrics/discover.py +68 -18
- package/metrics/learn.py +25 -1
- package/metrics/pacing.py +23 -3
- package/metrics/quota.py +41 -4
- package/metrics/quotaview.py +792 -0
- package/metrics/scheduler.py +2 -1
- package/metrics/sources.py +29 -6
- package/package.json +1 -1
- package/scripts/check.py +1 -0
- package/scripts/test_hubs.py +10 -5
- package/scripts/test_no_owui.py +9 -0
- package/scripts/test_quota.py +12 -2
- package/scripts/test_serve_edit.py +64 -2
- package/serve.py +311 -102
- package/ui/bench.css +92 -6
- package/ui/bench.js +544 -40
package/serve.py
CHANGED
|
@@ -221,6 +221,17 @@ def tag_for(llm_id: str) -> str:
|
|
|
221
221
|
return "" if tag == "?" else tag
|
|
222
222
|
|
|
223
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
|
+
|
|
224
235
|
def retired() -> dict[str, dict]:
|
|
225
236
|
"""Aliases removed from the bench by hand, with when and why."""
|
|
226
237
|
return load_json(DATA / "config" / "retired.json")
|
|
@@ -675,6 +686,17 @@ def remove_model(alias: str, reason: str = "", who: str = "ui") -> dict:
|
|
|
675
686
|
return {"ok": True, "errors": [], "changed": changed, "sync": _sync_cli()}
|
|
676
687
|
|
|
677
688
|
|
|
689
|
+
def dismiss_alert(key: str) -> dict:
|
|
690
|
+
"""Got it on a notice (an alert raised with dismiss=True, such as NVIDIA
|
|
691
|
+
changing its stated limits). A state that is still wrong is not dismissed:
|
|
692
|
+
its check clears it when it is put right."""
|
|
693
|
+
import alerts as _alerts
|
|
694
|
+
if not (_alerts.active().get(key) or {}).get("dismiss"):
|
|
695
|
+
return {"ok": False, "errors": [f"{key!r} is not a notice that can be dismissed"]}
|
|
696
|
+
_alerts.clear(key)
|
|
697
|
+
return {"ok": True, "errors": []}
|
|
698
|
+
|
|
699
|
+
|
|
678
700
|
def retry_model(alias: str, who: str = "ui") -> dict:
|
|
679
701
|
"""One fresh call to a model on a retiring alert. An answer is written to
|
|
680
702
|
perf/history.csv as evidence (scripts/auto_retire.py reads it, so the
|
|
@@ -826,15 +848,16 @@ def _modalities(entry: dict, served: str, index: dict) -> dict:
|
|
|
826
848
|
# What each endpoint answered, for LISTED_FOR seconds: name -> (when, its
|
|
827
849
|
# config, live, conns). Only the asking is kept; what is on the bench, retired
|
|
828
850
|
# or dismissed is taken off fresh on every opening.
|
|
829
|
-
LISTED_FOR =
|
|
851
|
+
LISTED_FOR = 3600 # an hour: lists rarely change sooner, and Refresh is one click
|
|
830
852
|
_listed: dict[str, tuple] = {}
|
|
831
853
|
|
|
832
854
|
|
|
833
855
|
def onboard_candidates(fresh: bool = False) -> dict:
|
|
834
856
|
"""What every endpoint serves that the bench could onboard, for the picker.
|
|
835
857
|
|
|
836
|
-
Served, minus registered, minus retired, minus
|
|
837
|
-
separately so a dismissal can be undone),
|
|
858
|
+
Served, minus registered, minus retired, minus queued or onboarding now,
|
|
859
|
+
minus dismissed (those come back separately so a dismissal can be undone),
|
|
860
|
+
each endpoint asked separately
|
|
838
861
|
so one being down only empties its own group. The live calls this page
|
|
839
862
|
makes happen only when the picker opens: a dashboard that needs the
|
|
840
863
|
network to render would be down exactly when you want to look at it.
|
|
@@ -858,6 +881,9 @@ def onboard_candidates(fresh: bool = False) -> dict:
|
|
|
858
881
|
| {endpoints.snapshot_key(r.get("served", ""), r.get("endpoint") or endpoints.default())
|
|
859
882
|
for r in retired().values()}
|
|
860
883
|
| {endpoints.snapshot_key(d["served"], d["endpoint"]) for d in decide.load().values()})
|
|
884
|
+
with _OQ_LOCK: # on its way: Start would refuse it anyway
|
|
885
|
+
taken |= {endpoints.snapshot_key(i["served"], i["endpoint"]) for i in _OQ["items"]
|
|
886
|
+
if i["state"] in ("queued", "running")}
|
|
861
887
|
tags = load_json(DATA / "config" / "provider-tags.json")
|
|
862
888
|
dismissed = load_json(DISMISSED_PATH)
|
|
863
889
|
first_seen = load_json(DATA / "config" / "served-snapshot.json").get("last_seen", {})
|
|
@@ -1457,12 +1483,18 @@ def collections_view() -> dict:
|
|
|
1457
1483
|
# What the tags check members against (bench.js tagBlocks): capabilities
|
|
1458
1484
|
# (None: the catalog does not say), context, price and role. A shape's
|
|
1459
1485
|
# tag admits the roles that shape admits, as route.py does for seats.
|
|
1486
|
+
# The member picker draws one row per model, its providers as chips:
|
|
1487
|
+
# `family` is route.family_of, the match that keeps one model out of two
|
|
1488
|
+
# seats, and `provider` the chip's name for its connection.
|
|
1489
|
+
import route
|
|
1460
1490
|
roles = model_roles.load()
|
|
1461
1491
|
facts = {}
|
|
1462
1492
|
for a in _chat_aliases():
|
|
1463
1493
|
f = catalog.info(a)
|
|
1494
|
+
tag = directory[a].get("tag") or directory[a].get("endpoint") or ""
|
|
1464
1495
|
facts[a] = {"caps": {c: f.get(c) for c in catalog.CAPS}, "context": f.get("context_window"),
|
|
1465
|
-
"price": price_of(a, served[a], f), "role": (roles.get("models") or {}).get(a, "general")
|
|
1496
|
+
"price": price_of(a, served[a], f), "role": (roles.get("models") or {}).get(a, "general"),
|
|
1497
|
+
"family": route.family_of(a), "provider": provider_label(tag), "connection": tag}
|
|
1466
1498
|
return {"ok": True, "errors": [], "collections": rows, "settings": s,
|
|
1467
1499
|
"shapes": shapes, "models": _chat_aliases(), "served": served, "tag_suggestions": suggestions,
|
|
1468
1500
|
"facts": facts, "shape_roles": {k: v for k, v in roles["shapes"].items() if k in shapes},
|
|
@@ -1567,13 +1599,29 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
|
|
|
1567
1599
|
|
|
1568
1600
|
# How much an onboarding spends. Full: the 4-call probe, then the suite now.
|
|
1569
1601
|
# Quick: the probe only; the scheduled `suite --next` passes fill the suite in,
|
|
1570
|
-
# six tasks a run, so the same calls are spread out, not saved.
|
|
1571
|
-
#
|
|
1572
|
-
#
|
|
1602
|
+
# six tasks a run, so the same calls are spread out, not saved. Ad hoc: the
|
|
1603
|
+
# bench never calls it on its own afterwards, for a metered, capped or trial
|
|
1604
|
+
# key; Full + ad hoc spends the suite once now so routing has scores for it,
|
|
1605
|
+
# Quick + ad hoc only the probe, and no scheduled pass ever fills its suite in
|
|
1606
|
+
# (suite/pace.py leaves ad hoc models out). Apart from the mode,
|
|
1573
1607
|
# `vision` (the picker's Vision model box) gives it role vision as it is
|
|
1574
1608
|
# registered: a seat only for requests that carry images, and its suite is the
|
|
1575
1609
|
# vision tasks alone (suite/run.applies), now or through the scheduled passes.
|
|
1576
|
-
ONBOARD_MODES = {"full": [], "
|
|
1610
|
+
ONBOARD_MODES = {"full": [], "full-adhoc": ["--ad-hoc"], "quick": ["--skip-suite"],
|
|
1611
|
+
"quick-adhoc": ["--skip-suite", "--ad-hoc"]}
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def onboard_choice(prefix: str) -> str:
|
|
1615
|
+
"""The mode and the Vision box, as the Onboard dialog (o-) and Discover's Add confirm (ask-) show them."""
|
|
1616
|
+
return (f"<label class='check'>Onboard as <select id='{prefix}-mode'>"
|
|
1617
|
+
"<option value='full'>Full: probe, then the suite now</option>"
|
|
1618
|
+
"<option value='full-adhoc'>Full + ad hoc: probe and the suite now; then the bench never calls it"
|
|
1619
|
+
" on its own</option>"
|
|
1620
|
+
"<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
|
|
1621
|
+
"<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
|
|
1622
|
+
" (metered, capped or trial keys)</option></select></label>"
|
|
1623
|
+
f"<label class='check'><input type='checkbox' id='{prefix}-vision'> Vision model: a seat only for"
|
|
1624
|
+
" questions about images, and its suite is the vision tasks</label>")
|
|
1577
1625
|
|
|
1578
1626
|
|
|
1579
1627
|
def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str = "full",
|
|
@@ -1780,13 +1828,17 @@ def _onboard_worker(who: str) -> None:
|
|
|
1780
1828
|
nxt.update(state=state, note=note, exit=code)
|
|
1781
1829
|
|
|
1782
1830
|
|
|
1783
|
-
def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = ""
|
|
1831
|
+
def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = "",
|
|
1832
|
+
mode: str = "full", vision: bool = False) -> dict:
|
|
1784
1833
|
"""From the Discover tab: put a model into a hub as it is otherwise done by
|
|
1785
1834
|
hand, through the adapter of the hub `endpoint` is (endpoints.adapter:
|
|
1786
1835
|
Open WebUI's connection Model IDs and a public, tagged record with a logo;
|
|
1787
1836
|
a LiteLLM deployment; a CLI Proxy API provider's models list), then the
|
|
1788
|
-
normal onboarding
|
|
1837
|
+
normal onboarding, in the mode and with the Vision box the confirm chose
|
|
1838
|
+
(ONBOARD_MODES). Only for a connection the hub already has. No endpoint
|
|
1789
1839
|
means Open WebUI's, as before there were other hubs."""
|
|
1840
|
+
if mode not in ONBOARD_MODES: # before the hub has it, not after
|
|
1841
|
+
return {"ok": False, "errors": [f"mode must be one of {', '.join(ONBOARD_MODES)}; got {mode!r}"]}
|
|
1790
1842
|
if not re.fullmatch(r"[\w./:@+-]{1,200}", model or ""):
|
|
1791
1843
|
return {"ok": False, "errors": ["the model id is the provider's id, e.g. vendor/model:free"]}
|
|
1792
1844
|
if not re.fullmatch(r"[\w.-]{1,80}", connection or ""):
|
|
@@ -1805,7 +1857,7 @@ def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = "
|
|
|
1805
1857
|
return {"ok": False, "errors": [f"{label} refused: {type(exc).__name__}: {exc}"]}
|
|
1806
1858
|
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "",
|
|
1807
1859
|
f"{which}-add", None, f"{served} on {connection}")
|
|
1808
|
-
started = queue_onboard([{"served": served, "endpoint": hub.endpoint()}], who)
|
|
1860
|
+
started = queue_onboard([{"served": served, "endpoint": hub.endpoint(), "mode": mode, "vision": vision}], who)
|
|
1809
1861
|
return {**started, "served": served, "endpoint": hub.endpoint(), "steps": steps,
|
|
1810
1862
|
"errors": started["errors"] + ([] if started["ok"] else
|
|
1811
1863
|
[f"{label} now has {served}; onboard it from the Onboard dialog"])}
|
|
@@ -1964,14 +2016,26 @@ def problems() -> str:
|
|
|
1964
2016
|
KIND = {"retiring": "err", "retired": "err", "connection": "warn",
|
|
1965
2017
|
"registration": "warn", "plan": "warn"}
|
|
1966
2018
|
items = []
|
|
2019
|
+
live = set(names.values())
|
|
1967
2020
|
for key, a in sorted(alerts.items(), key=lambda kv: kv[1].get("first_seen", "")):
|
|
1968
2021
|
served = key.split(":", 2)[2] if key.count(":") >= 2 else ""
|
|
1969
2022
|
alias = a.get("alias") or names.get(served, "")
|
|
2023
|
+
# The alerts file is rewritten by the daily check; until then an alert
|
|
2024
|
+
# whose models have all left the bench (Retire all, or one by one) has
|
|
2025
|
+
# nothing left to act on.
|
|
2026
|
+
if a.get("aliases") and not live & set(a["aliases"]):
|
|
2027
|
+
continue
|
|
2028
|
+
if a.get("kind") == "retiring" and alias and alias not in live:
|
|
2029
|
+
continue
|
|
1970
2030
|
try:
|
|
1971
2031
|
age = (today - datetime.date.fromisoformat(a["first_seen"])).days
|
|
1972
2032
|
except (KeyError, ValueError):
|
|
1973
2033
|
age = 0
|
|
1974
|
-
|
|
2034
|
+
# Which model and where it comes from, first: the message alone left that to be looked up.
|
|
2035
|
+
served_as = next((s for s, al in names.items() if al == alias), served) if alias else ""
|
|
2036
|
+
where = (f"{alias} · {served_as or '?'} via {tag_for(alias) or a.get('endpoint') or '?'}"
|
|
2037
|
+
if alias else "")
|
|
2038
|
+
why = " · ".join(filter(None, [where, a.get("action", ""),
|
|
1975
2039
|
"open today" if age == 0 else f"open {age} days"]))
|
|
1976
2040
|
acts = []
|
|
1977
2041
|
if a.get("aliases"):
|
|
@@ -1985,10 +2049,15 @@ def problems() -> str:
|
|
|
1985
2049
|
acts.append(f"<button class='btn small' data-act='open' data-alias='{esc(alias)}'>Open</button>")
|
|
1986
2050
|
acts.append(f"<button class='btn small danger' data-act='retire' data-alias='{esc(alias)}'"
|
|
1987
2051
|
f" data-why='{esc(a.get('message', ''))}'>Retire now</button>")
|
|
2052
|
+
if a.get("dismiss"): # a notice, not a state: it goes once seen
|
|
2053
|
+
acts.append(f"<button class='btn small' data-act='dismiss' data-key='{esc(key)}'>Got it</button>")
|
|
1988
2054
|
items.append((key, a.get("kind", "?"), KIND.get(a.get("kind"), "warn"),
|
|
1989
2055
|
a.get("message", ""), why, "".join(acts)))
|
|
1990
2056
|
cutoff = (today - datetime.timedelta(days=7)).isoformat()
|
|
1991
|
-
|
|
2057
|
+
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2058
|
+
auto = [(f"retired:{alias}:{r['at']}", "", "",
|
|
2059
|
+
f"{alias} · {r.get('served') or '?'} via {tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default()}"
|
|
2060
|
+
f" · {r['at'][:10]}",
|
|
1992
2061
|
r.get("why", "").removeprefix("auto: "),
|
|
1993
2062
|
f"<button class='btn small' data-act='restore' data-alias='{esc(alias)}'>Restore</button>")
|
|
1994
2063
|
for alias, r in sorted(retired().items(), key=lambda kv: kv[1].get("at", ""), reverse=True)
|
|
@@ -2204,14 +2273,15 @@ def on_bench() -> set[str]:
|
|
|
2204
2273
|
return set(registered().values())
|
|
2205
2274
|
|
|
2206
2275
|
|
|
2207
|
-
def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = ""
|
|
2276
|
+
def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = "",
|
|
2277
|
+
retired: bool = True) -> str:
|
|
2208
2278
|
"""A Benchmarks panel: the chart first (drawn by bench.js with Observable
|
|
2209
2279
|
Plot), the table folded under it, the key under that."""
|
|
2210
2280
|
return (f"<div class='panel'><h2>{title}</h2><p class='sub'>{sub}</p>{extra}"
|
|
2211
2281
|
f"<div class='chart' id='{chart_id}'></div>"
|
|
2212
2282
|
"<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
|
|
2213
2283
|
" data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
|
|
2214
|
-
"<label><input type='checkbox' data-retired> Show retired</label
|
|
2284
|
+
+ ("<label><input type='checkbox' data-retired> Show retired</label>" if retired else "") + "</div>"
|
|
2215
2285
|
f"<div class='scroll'>{table}</div></details>"
|
|
2216
2286
|
f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
|
|
2217
2287
|
|
|
@@ -2590,6 +2660,8 @@ def suite_card() -> str:
|
|
|
2590
2660
|
|
|
2591
2661
|
|
|
2592
2662
|
EXTERNAL = (("LiveBench", "https://livebench.ai/", "contamination-limited questions, refreshed monthly"),
|
|
2663
|
+
("FreeLLMAPI", "https://freellmapi.co/models",
|
|
2664
|
+
"every free tier: limits, ranks and quirks per model (Discover leads with it)"),
|
|
2593
2665
|
("models.dev", "https://models.dev/", "specs and prices per provider (the catalog reads it daily)"),
|
|
2594
2666
|
("BullshitBench", "https://petergpt.github.io/bullshit-benchmark/viewer/index.next.html",
|
|
2595
2667
|
"pushing back on nonsense; the idea behind confidently wrong"))
|
|
@@ -2788,7 +2860,7 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2788
2860
|
judged = {}
|
|
2789
2861
|
owed = seats_total = 0
|
|
2790
2862
|
body = ["<table><thead><tr>", th("When"), th("Collection"), th("Asked by"), th("Explorer shape"),
|
|
2791
|
-
th("Seats"), th("
|
|
2863
|
+
th("Seats"), th("Time", True), "</tr></thead><tbody>"]
|
|
2792
2864
|
for r in records:
|
|
2793
2865
|
chips, row_owed = [], 0
|
|
2794
2866
|
for seat in r.get("seats", []):
|
|
@@ -2808,13 +2880,20 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2808
2880
|
chips += [f"<span class='chip muted' title='{esc('not asked: ' + str(k.get('why', '')))}'>"
|
|
2809
2881
|
f"{esc(k.get('alias', ''))} –</span>" for k in r.get("skipped", []) if isinstance(k, dict)]
|
|
2810
2882
|
owed += row_owed
|
|
2883
|
+
# The request's own time: its seats ran at once, so the slowest of
|
|
2884
|
+
# them, then the merge (a merger seat answers after the rest).
|
|
2885
|
+
secs = [(s.get("seat"), float(s.get("seconds") or 0)) for s in r.get("seats", [])]
|
|
2886
|
+
panel = max((t for seat, t in secs if seat != "merger"), default=0.0)
|
|
2887
|
+
merge = sum(t for seat, t in secs if seat == "merger")
|
|
2888
|
+
took = (f"<td class='num' data-sort='{panel + merge:.1f}' title='slowest seat {panel:.1f}s"
|
|
2889
|
+
f"{f' + merge {merge:.1f}s' if merge else ''}'>{panel + merge:.0f}s</td>" if secs
|
|
2890
|
+
else "<td class='num muted' data-sort='-1'>—</td>")
|
|
2811
2891
|
body.append(
|
|
2812
2892
|
f"<tr><td class='mono' data-sort='{esc(r.get('at', ''))}'>{esc(r.get('at', '')[:16].replace('T', ' '))}</td>"
|
|
2813
2893
|
f"<td class='mono'>{esc(r.get('model', ''))}</td>"
|
|
2814
2894
|
f"<td class='mono'>{esc(r.get('key') or '—')}</td>"
|
|
2815
2895
|
f"<td title='{esc(r.get('shape_by', ''))}'>{esc(r.get('shape', ''))}</td>"
|
|
2816
|
-
f"<td>{' '.join(chips)}</td>"
|
|
2817
|
-
f"<td class='num{' muted' if not row_owed else ''}'>{row_owed}</td></tr>")
|
|
2896
|
+
f"<td>{' '.join(chips)}</td>{took}</tr>")
|
|
2818
2897
|
if not records:
|
|
2819
2898
|
body.append("<tr><td colspan='6' class='muted'>No gateway requests yet. Call a collection"
|
|
2820
2899
|
f" at <code>{esc(gateway_url())}</code>.</td></tr>")
|
|
@@ -2829,7 +2908,10 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2829
2908
|
carried none. Hover it for why.</dd>
|
|
2830
2909
|
<dt>Asked by</dt><dd>The gateway key that made the request, or <code>local</code>
|
|
2831
2910
|
when no key is asked for.</dd>
|
|
2832
|
-
<dt>
|
|
2911
|
+
<dt>Time</dt><dd>How long the caller waited: the seats run at once, so the
|
|
2912
|
+
slowest of them, plus the merge when the reply was merged. Hover for the two
|
|
2913
|
+
parts; a chip's own time is on the chip.</dd>
|
|
2914
|
+
<dt>Verdicts owed</dt><dd>A chip with no mark. Each is recorded as an unjudged
|
|
2833
2915
|
offload, so rotation still counts it; a verdict tells the router whether
|
|
2834
2916
|
it helped. The orchestrator that called can also record one:
|
|
2835
2917
|
<code>python metrics/offload.py verdict <call id> --useful</code>.</dd>
|
|
@@ -2968,7 +3050,7 @@ def discover_model(provider: str, model: str) -> dict:
|
|
|
2968
3050
|
|
|
2969
3051
|
|
|
2970
3052
|
def discover_card() -> str:
|
|
2971
|
-
"""Models worth adding, from models.dev
|
|
3053
|
+
"""Models worth adding, from FreeLLMAPI and models.dev (metrics/discover.py),
|
|
2972
3054
|
deduped. Read from the daily cache, so the page never waits on the network;
|
|
2973
3055
|
built once per cache refresh. A click on a row opens its popup (bench.js),
|
|
2974
3056
|
which asks /api/discover-model for the rest: its routes are where Add is."""
|
|
@@ -2976,7 +3058,8 @@ def discover_card() -> str:
|
|
|
2976
3058
|
rows = [r for r in all_rows if r["free"] or r["reach"] in ("connected", "on bench")]
|
|
2977
3059
|
hidden = len(all_rows) - len(rows)
|
|
2978
3060
|
reach_css = {"on bench": "ok", "connected": "ok", "easy to add": "warn", "other": ""}
|
|
2979
|
-
body = ["<table><thead><tr>", th("Model"), th("
|
|
3061
|
+
body = ["<table><thead><tr>", th("Model"), th("Rank", True), th("Provider"), th("Reach"), th("Price"),
|
|
3062
|
+
th("Context", True),
|
|
2980
3063
|
th("In → out"), th("Can"), th("Released"), th("Limits"), "<th></th></tr></thead><tbody>"]
|
|
2981
3064
|
for r in rows:
|
|
2982
3065
|
caps = [c for c, on in (("tools", r["tools"]), ("vision", r["vision"]),
|
|
@@ -2985,8 +3068,13 @@ def discover_card() -> str:
|
|
|
2985
3068
|
price = ("free" if r["free"] else
|
|
2986
3069
|
f"{cost.get('input', '?')} / {cost.get('output', '?')}" if cost else "paid")
|
|
2987
3070
|
lim = r.get("limits") or {}
|
|
2988
|
-
lim_text = " · ".join(f"{v:,} {k}" for k, v in lim.items()
|
|
3071
|
+
lim_text = " · ".join([f"{v:,} {k}" for k, v in lim.items()]
|
|
3072
|
+
+ ([f"{r['budget']} tokens a month"] if r.get("budget") else []))
|
|
2989
3073
|
ctx = r.get("context")
|
|
3074
|
+
rank = (f"<td class='num' data-sort='{r['rank']}' title='FreeLLMAPI: smartest #{r['rank']}"
|
|
3075
|
+
+ (f", fastest #{r['speed']}" if r.get("speed") else "")
|
|
3076
|
+
+ (f", {esc(r['size'])}" if r.get("size") else "") + f"'>{r['rank']}</td>"
|
|
3077
|
+
if r.get("rank") is not None else "<td class='num muted' data-sort='99999'>—</td>")
|
|
2990
3078
|
add = ("<button class='btn small' type='button'>Add…</button>"
|
|
2991
3079
|
if any(not x["on_bench_as"] for x in r["routes"]) else "")
|
|
2992
3080
|
body.append(
|
|
@@ -2997,8 +3085,8 @@ def discover_card() -> str:
|
|
|
2997
3085
|
+ f"<span class='conn'>{esc(r['model'])}"
|
|
2998
3086
|
+ (f" · on bench as {esc(r['on_bench_as'])}" if r.get("on_bench_as") else "")
|
|
2999
3087
|
+ (f" · also on bench as {esc(', '.join(r['also_on']))}" if r.get("also_on") else "")
|
|
3000
|
-
+ "</span></td>"
|
|
3001
|
-
f"<td title='{esc(r['provider'])}'>{esc(r['provider_name'])}</td>"
|
|
3088
|
+
+ "</span></td>" + rank
|
|
3089
|
+
+ f"<td title='{esc(r['provider'])}'>{esc(r['provider_name'])}</td>"
|
|
3002
3090
|
f"<td data-sort='{['on bench', 'connected', 'easy to add', 'other'].index(r['reach'])}'>"
|
|
3003
3091
|
f"{chip(r['reach'], reach_css[r['reach']], r.get('api') or '')}</td>"
|
|
3004
3092
|
f"<td title='per million tokens, input / output'>{esc(price)}</td>"
|
|
@@ -3015,31 +3103,40 @@ def discover_card() -> str:
|
|
|
3015
3103
|
" provider or reach' aria-label='Filter rows'>"
|
|
3016
3104
|
"<label><input type='checkbox' data-paid> Show paid (connected providers)</label></div>")
|
|
3017
3105
|
key = """<dl>
|
|
3106
|
+
<dt>Rank</dt><dd>FreeLLMAPI's smartest-first order (1 is the smartest it lists); within
|
|
3107
|
+
each reach the list follows it. Hover for its fastest-first rank and size. A dash where
|
|
3108
|
+
only models.dev lists the model.</dd>
|
|
3018
3109
|
<dt>Provider</dt><dd>Who serves the model: a row per provider, so the same model
|
|
3019
|
-
from OpenRouter and from NVIDIA is two rows. Hover for its
|
|
3110
|
+
from OpenRouter and from NVIDIA is two rows. Hover for its id.</dd>
|
|
3020
3111
|
<dt>Reach</dt><dd><em>on bench</em> already registered; <em>connected</em> this
|
|
3021
3112
|
install has a route to the provider (an Open WebUI connection, or an endpoint of its
|
|
3022
3113
|
own); <em>easy to add</em> an OpenAI-compatible API, so one new connection away (hover
|
|
3023
3114
|
for its base URL); <em>other</em> a native API or an unvetted reseller.</dd>
|
|
3024
|
-
<dt>Price</dt><dd>Free if
|
|
3025
|
-
|
|
3115
|
+
<dt>Price</dt><dd>Free if FreeLLMAPI lists it (a catalog of free tiers) or models.dev
|
|
3116
|
+
prices it at 0: models.dev shows the PAID price even where a free tier exists. Otherwise
|
|
3026
3117
|
input / output per million tokens.</dd>
|
|
3118
|
+
<dt>Limits</dt><dd>The free tier's, from FreeLLMAPI: requests and tokens per minute (rpm,
|
|
3119
|
+
tpm) and per day (rpd, tpd), and roughly how many tokens a month that allows.</dd>
|
|
3027
3120
|
<dt>New</dt><dd>First seen in either list in the last 7 days.</dd>
|
|
3028
3121
|
<dt>Add</dt><dd>Click a row: its popup has everything known about the model and every
|
|
3029
3122
|
route this install has to it, each with <em>Add via this</em>. <em>Via Open WebUI</em>
|
|
3030
3123
|
adds the model to that connection's Model IDs (when it lists them), creates its model
|
|
3031
3124
|
record public, tagged like the connection's other models and with their logo, then runs
|
|
3032
|
-
the bench's onboarding (
|
|
3125
|
+
the bench's onboarding as the confirm chooses (Onboard as, Vision model), so it is in
|
|
3126
|
+
the chats too.
|
|
3033
3127
|
<em>Via LiteLLM</em> or <em>CLI Proxy API</em> adds it to that hub's connection (one more
|
|
3034
3128
|
deployment, or the provider's models list); those hubs are asked when the popup opens.
|
|
3035
3129
|
<em>Direct</em> onboards it on the provider's own endpoint: bench only. A new provider
|
|
3036
3130
|
needs a connection or an endpoint first; hover its <em>easy to add</em> chip for the
|
|
3037
3131
|
base URL.</dd>
|
|
3038
|
-
<dt>Sources</dt><dd>
|
|
3039
|
-
|
|
3132
|
+
<dt>Sources</dt><dd>FreeLLMAPI's free catalog first: where it lists a model, its name,
|
|
3133
|
+
provider, context, tools, limits, ranks and quirks lead, and the popup links its
|
|
3134
|
+
freellmapi.co page. models.dev fills the rest and alone has the paid models, prices,
|
|
3135
|
+
base URLs and modalities. A model one provider offers in both is shown once. Both are
|
|
3136
|
+
refreshed at most daily.</dd>
|
|
3040
3137
|
</dl>"""
|
|
3041
|
-
return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from
|
|
3042
|
-
f" and
|
|
3138
|
+
return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from FreeLLMAPI"
|
|
3139
|
+
f" and models.dev, deduped; {hidden:,} paid models on providers you have no route"
|
|
3043
3140
|
f" to are left out. Click a model for everything known about it and the routes it can be"
|
|
3044
3141
|
f" added by.</p>{toolbar}"
|
|
3045
3142
|
f"<div class='scroll'>{''.join(body)}</div>"
|
|
@@ -3083,8 +3180,13 @@ def collections_tab() -> str:
|
|
|
3083
3180
|
# Not data-filter: that binds to the whole panel. The same match (any
|
|
3084
3181
|
# text, case-insensitive), on the member list only; ticked ones stay shown.
|
|
3085
3182
|
"<label class='field' for='c-member-filter'>Members</label>"
|
|
3086
|
-
|
|
3087
|
-
|
|
3183
|
+
# The panel as it will sit, drawn as the Collections list draws it
|
|
3184
|
+
# (bench.js recount): a seat per ticked member, explorer seats dashed.
|
|
3185
|
+
"<div id='c-seats' class='seats' aria-live='polite'></div>"
|
|
3186
|
+
"<div class='member-find'><input type='search' id='c-member-filter' class='member-filter' spellcheck='false'"
|
|
3187
|
+
" autocomplete='off' placeholder='Filter by model, provider or served name' aria-controls='c-members'"
|
|
3188
|
+
" aria-describedby='c-count'><select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
|
|
3189
|
+
"<option value=''>Every provider</option></select></div>"
|
|
3088
3190
|
"<div id='c-members' class='members' role='group' aria-label='Members'></div>"
|
|
3089
3191
|
"<p class='rules' id='c-count'></p>"
|
|
3090
3192
|
"<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
|
|
@@ -3295,12 +3397,71 @@ def access_card() -> str:
|
|
|
3295
3397
|
body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td>{esc(v)}</td>"
|
|
3296
3398
|
f"<td class='mono set-by'>{'<br>'.join(esc(s) for s in src.split(', '))}</td></tr>"
|
|
3297
3399
|
for k, v, src in rows)
|
|
3400
|
+
running, disk = on_disk()
|
|
3401
|
+
now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
|
|
3402
|
+
f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
|
|
3298
3403
|
return ("<div class='panel'><h2>Server and access</h2><p class='muted'>How this install is reached and who"
|
|
3299
3404
|
" may call it. Set in the environment, read at start: change one and restart. Shown here, never"
|
|
3300
3405
|
" set here, since a page that could change its own login could open itself to anyone. No secret"
|
|
3301
3406
|
" is shown, only whether one is set.</p>"
|
|
3302
3407
|
f"<table class='facts'><thead><tr>{th('Setting')}{th('Now')}{th('Set by')}</tr></thead>"
|
|
3303
|
-
f"<tbody>{body}</tbody></table
|
|
3408
|
+
f"<tbody>{body}</tbody></table>"
|
|
3409
|
+
# restart(): stopped and started again by serve.py's own parent process.
|
|
3410
|
+
f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
|
|
3411
|
+
" for an update or a setting"
|
|
3412
|
+
" above, whatever runs it (a service, the launcher, a container or a terminal). It waits for"
|
|
3413
|
+
" onboarding, a scheduled job and gateway calls in progress, or cuts them off if you insist; the"
|
|
3414
|
+
" page comes back in a few seconds.</p>"
|
|
3415
|
+
"<p><button class='btn' type='button' id='restart'>Restart Benchwarmers</button>"
|
|
3416
|
+
" <span id='restart-out' role='status'></span></p></div>")
|
|
3417
|
+
|
|
3418
|
+
|
|
3419
|
+
RESTART = 75 # the exit code serve.py's parent reads as "start me again"
|
|
3420
|
+
SERVER = None # the running ThreadingHTTPServer, set in __main__
|
|
3421
|
+
_answering = [0] # gateway calls being answered now
|
|
3422
|
+
_answering_lock = threading.Lock()
|
|
3423
|
+
|
|
3424
|
+
|
|
3425
|
+
def _follow_parent() -> None:
|
|
3426
|
+
"""End with serve.py's parent however it went (a service stopped, a
|
|
3427
|
+
terminal closed), so no orphan keeps the port. Its process is waited on,
|
|
3428
|
+
not a pipe read: on Windows a thread blocked reading stdin stalls every
|
|
3429
|
+
subprocess started meanwhile (git, the onboarding, the jobs)."""
|
|
3430
|
+
ppid = int(os.environ.get("BENCH_PARENT") or 0)
|
|
3431
|
+
if not ppid:
|
|
3432
|
+
return
|
|
3433
|
+
if os.name == "nt":
|
|
3434
|
+
import ctypes
|
|
3435
|
+
k32 = ctypes.windll.kernel32
|
|
3436
|
+
handle = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
|
|
3437
|
+
if not handle:
|
|
3438
|
+
return # cannot watch it: stay up
|
|
3439
|
+
k32.WaitForSingleObject(handle, 0xFFFFFFFF) # until it ends
|
|
3440
|
+
else:
|
|
3441
|
+
while os.getppid() == ppid: # a new parent: the old one ended
|
|
3442
|
+
time.sleep(2)
|
|
3443
|
+
os._exit(0)
|
|
3444
|
+
|
|
3445
|
+
|
|
3446
|
+
def restart(force: bool = False, who: str = "ui") -> dict:
|
|
3447
|
+
"""End this dashboard and start it again on the code now on disk: after
|
|
3448
|
+
an update, or a setting read at start. serve.py runs it as a child of its
|
|
3449
|
+
own parent process (see __main__), which starts it again, so this works
|
|
3450
|
+
however serve.py was started: Servy, the npm or pip launcher, Docker or a
|
|
3451
|
+
terminal. Refused while something would be cut off, unless `force`."""
|
|
3452
|
+
import scheduler
|
|
3453
|
+
if os.environ.get("BENCH_SERVED") != "1" or SERVER is None:
|
|
3454
|
+
return {"ok": False, "errors": ["this dashboard was not started by serve.py's own parent process,"
|
|
3455
|
+
" so nothing would start it again: restart it the way you started it"]}
|
|
3456
|
+
busy = ([f"onboarding is running ({sum(i['state'] in ('queued', 'running') for i in _OQ['items'])} left):"
|
|
3457
|
+
" Stop it in the queue, or wait"] if _OQ["worker"] is not None else []) \
|
|
3458
|
+
+ ([f"a scheduled job is running: {', '.join(scheduler.RUNNING)}"] if scheduler.RUNNING else []) \
|
|
3459
|
+
+ ([f"{_answering[0]} gateway call(s) are being answered"] if _answering[0] else [])
|
|
3460
|
+
if busy and not force:
|
|
3461
|
+
return {"ok": False, "busy": True, "errors": busy}
|
|
3462
|
+
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "", "restart", *on_disk())
|
|
3463
|
+
threading.Timer(0.5, SERVER.shutdown).start() # after this reply has gone
|
|
3464
|
+
return {"ok": True, "errors": [], "cut": busy}
|
|
3304
3465
|
|
|
3305
3466
|
|
|
3306
3467
|
def save_scheduler(mode: str, jobs, who: str = "ui") -> dict:
|
|
@@ -3591,27 +3752,45 @@ def models_page() -> str:
|
|
|
3591
3752
|
+ "".join(cards))
|
|
3592
3753
|
|
|
3593
3754
|
|
|
3755
|
+
def quota_tiles() -> list[dict]:
|
|
3756
|
+
"""metrics/quotaview.py's tiles, each named as the collection popup names
|
|
3757
|
+
its provider, unless the live read named the credentials behind it."""
|
|
3758
|
+
import quotaview
|
|
3759
|
+
return [dict(t, name=t["name"] or provider_label(t["tag"])) for t in quotaview.tiles()]
|
|
3760
|
+
|
|
3761
|
+
|
|
3762
|
+
def quota_op(which: str, tag) -> dict:
|
|
3763
|
+
"""Refresh on one tile, which picks a free read, one short call or the
|
|
3764
|
+
records (quotaview.refresh): {ok, errors, note, tile}; "order" saves the
|
|
3765
|
+
tiles' order (a list of tags, empty for the default sort)."""
|
|
3766
|
+
import quotaview
|
|
3767
|
+
if which == "order":
|
|
3768
|
+
return quotaview.save_order(tag)
|
|
3769
|
+
if tag not in {t["id"] for t in quota_tiles()}:
|
|
3770
|
+
return {"ok": False, "errors": [f"no tile called {tag!r}"]}
|
|
3771
|
+
if which == "about": # the Models popup: how it is reached
|
|
3772
|
+
try:
|
|
3773
|
+
return {"ok": True, "errors": [], **quotaview.about(tag)}
|
|
3774
|
+
except (ValueError, OSError) as exc:
|
|
3775
|
+
return {"ok": False, "errors": [str(exc)]}
|
|
3776
|
+
try:
|
|
3777
|
+
r = quotaview.refresh(tag)
|
|
3778
|
+
except (ValueError, OSError) as exc: # OSError: URLError and timeouts
|
|
3779
|
+
return {"ok": False, "errors": [str(exc)]}
|
|
3780
|
+
if not r.get("ok"):
|
|
3781
|
+
return {"ok": False, "errors": r.get("errors") or ["it failed"]}
|
|
3782
|
+
return {"ok": True, "errors": [], "note": r.get("note", ""),
|
|
3783
|
+
"tile": next((t for t in quota_tiles() if t["id"] == tag), None)}
|
|
3784
|
+
|
|
3785
|
+
|
|
3594
3786
|
def quotas_page() -> str:
|
|
3595
|
-
"""
|
|
3596
|
-
|
|
3597
|
-
|
|
3787
|
+
"""A tile per provider: what is left and when it resets, shaped by how the
|
|
3788
|
+
provider limits (metrics/quotaview.py), who is resting, one Refresh; the
|
|
3789
|
+
resets of the next week; what the bench sent (its usage ledger,
|
|
3790
|
+
metrics/ledger.py); every limit known. bench.js draws tiles and charts."""
|
|
3598
3791
|
import learn
|
|
3599
3792
|
import ledger
|
|
3600
|
-
import quota
|
|
3601
3793
|
now = datetime.datetime.now().astimezone()
|
|
3602
|
-
rec_rows = []
|
|
3603
|
-
for r in sorted(quota.live(), key=lambda r: (r.get("connection", ""), r.get("alias", ""), r.get("metric", ""))):
|
|
3604
|
-
try:
|
|
3605
|
-
age = int((now - datetime.datetime.fromisoformat(r["observed_at"])).total_seconds() // 60)
|
|
3606
|
-
reset = datetime.datetime.fromisoformat(r["reset_at"]).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
3607
|
-
except (KeyError, TypeError, ValueError):
|
|
3608
|
-
continue
|
|
3609
|
-
num = lambda v: f"{v:,}" if isinstance(v, int) else "—" # noqa: E731
|
|
3610
|
-
rec_rows.append(f"<tr><td>{esc(r.get('connection', ''))}</td><td>{named(r.get('alias', ''))}</td>"
|
|
3611
|
-
f"<td>{esc(r.get('metric', ''))} / {esc(r.get('window', ''))}</td>"
|
|
3612
|
-
f"<td class='num'>{num(r.get('remaining'))}</td><td class='num'>{num(r.get('limit'))}</td>"
|
|
3613
|
-
f"<td class='mono'>{esc(reset)}</td>"
|
|
3614
|
-
f"<td>{esc(r.get('source', ''))}, {age} min ago</td></tr>")
|
|
3615
3794
|
t = now.timestamp()
|
|
3616
3795
|
sent: dict = {}
|
|
3617
3796
|
for row in ledger.rows(min(t - 86400, ledger.month_start(t))):
|
|
@@ -3623,15 +3802,10 @@ def quotas_page() -> str:
|
|
|
3623
3802
|
s[2], s[3] = s[2] + n, s[3] + tok
|
|
3624
3803
|
sent_rows = [f"<tr><td>{esc(c)}</td>" + "".join(f"<td class='num'>{v:,}</td>" for v in s) + "</tr>"
|
|
3625
3804
|
for c, s in sorted(sent.items())]
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
except (KeyError, ValueError, TypeError):
|
|
3631
|
-
left = 0
|
|
3632
|
-
cool_rows.append(f"<tr><td>{named(alias)}</td><td>{esc(catalog.connection_of(alias))}</td>"
|
|
3633
|
-
f"<td class='num' data-sort='{left}'>{left} min</td>"
|
|
3634
|
-
f"<td>{'stated by the provider' if c.get('stated') else 'default wait'}</td></tr>")
|
|
3805
|
+
data = json.dumps({"tiles": quota_tiles(),
|
|
3806
|
+
"sent": [{"provider": provider_label(c) if c != "?" else "?", "day": s[0], "month": s[2]}
|
|
3807
|
+
for c, s in sent.items() if s[2]]},
|
|
3808
|
+
ensure_ascii=False).replace("</", "<\\/")
|
|
3635
3809
|
entered = catalog.connection_limits()
|
|
3636
3810
|
keys = catalog.CONNECTION_LIMIT_KEYS
|
|
3637
3811
|
lim_rows = [f"<tr><td>{esc(conn)}</td>" + "".join(
|
|
@@ -3646,40 +3820,44 @@ def quotas_page() -> str:
|
|
|
3646
3820
|
+ "".join(f"<td class='num'>{ev['value']:,}</td>" if kk == k
|
|
3647
3821
|
else "<td class='num muted'>—</td>" for kk in keys)
|
|
3648
3822
|
+ f"<td>learned {esc(ev.get('at', '')[:10])} ({esc(ev.get('where', ''))})</td></tr>")
|
|
3823
|
+
sent_table = ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
|
|
3824
|
+
+ th("Requests, this month", True) + th("Tokens, this month", True)
|
|
3825
|
+
+ f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
|
|
3826
|
+
if sent_rows else "<p class='muted'>No call in the ledger this month.</p>")
|
|
3827
|
+
sent_key = ("<p>One row per provider, most requests this month first. The pale bar is this month's"
|
|
3828
|
+
" requests, the dark one inside it the last 24 hours'; the label reads 24 h / month."
|
|
3829
|
+
" Retries count. Calls through the llm CLI are not in the ledger.</p>")
|
|
3649
3830
|
return (
|
|
3650
|
-
"<div class='panel'><h2>Quota
|
|
3651
|
-
"
|
|
3652
|
-
"
|
|
3653
|
-
"
|
|
3654
|
-
"
|
|
3655
|
-
"
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
"<div class='panel'><h3 class='chart-head'>
|
|
3662
|
-
" (
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
"
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
else "<p class='muted'>None: every model is free to call.</p>")
|
|
3672
|
-
+ "</div>"
|
|
3673
|
-
"<div class='panel'><h3 class='chart-head'>Known limits</h3><p class='sub'>How much each connection"
|
|
3674
|
-
" or model allows (tokens or requests per minute or day), so the bench can space its calls."
|
|
3675
|
-
" Two sources: what you enter per connection (Endpoints, Rate limits per connection), and what a"
|
|
3676
|
-
" provider states in a rate-limit error, which the bench writes down by itself. The list grows on"
|
|
3677
|
-
" its own as models meet their limits. A model's own limits (its popup) win, and learned ones"
|
|
3678
|
-
" only ever lower a limit.</p>"
|
|
3831
|
+
"<div class='panel'><h2>Quota</h2><p class='sub'>What is left with each provider and when it"
|
|
3832
|
+
" resets, and who is resting after a rate limit. Refresh asks the provider for free where it can"
|
|
3833
|
+
" (CLI Proxy API's credentials, OpenRouter); where a provider states its limits only in its replies,"
|
|
3834
|
+
" it sends one 16-token request to read them; elsewhere it reads the bench's own records again."
|
|
3835
|
+
" Hover a Refresh to see which it will do."
|
|
3836
|
+
" Drag a tile, or move it with the arrow keys, to keep it where you want it.</p>"
|
|
3837
|
+
"<div class='list-actions'><button class='btn' id='q-all'>Refresh all live</button>"
|
|
3838
|
+
"<button class='btn' id='q-reorder'>Sort: out first, then soonest reset</button>"
|
|
3839
|
+
"<span class='done' id='q-done' aria-live='polite'></span>"
|
|
3840
|
+
"<span class='errors' id='q-errors' aria-live='polite'></span></div>"
|
|
3841
|
+
"<div class='q-tiles' id='q-tiles'></div></div>"
|
|
3842
|
+
"<div class='panel'><h3 class='chart-head'>Resets, next 7 days</h3><p class='sub'>A dot where a"
|
|
3843
|
+
" limit resets (a daily one every day), a bar while a model rests, the line is now.</p>"
|
|
3844
|
+
"<div class='chart' id='chart-resets'></div></div>"
|
|
3845
|
+
+ chart_card("What the bench sent", "Requests per provider: this month, and the last 24 hours inside it.",
|
|
3846
|
+
"chart-sent", sent_table, sent_key, retired=False)
|
|
3847
|
+
+ "<details class='panel'><summary><b>Known limits</b></summary><p class='sub'>How much each"
|
|
3848
|
+
" connection or model allows (tokens or requests per minute or day), so the bench can space its"
|
|
3849
|
+
" calls. Two sources: what you enter per connection (Endpoints, Rate limits per connection), and"
|
|
3850
|
+
" what a provider states in a rate-limit error, which the bench writes down by itself. A model's"
|
|
3851
|
+
" own limits (its popup) win, and learned ones only ever lower a limit.</p>"
|
|
3679
3852
|
+ ("<table><thead><tr>" + th("Connection or model") + "".join(th(k, True) for k in keys) + th("From")
|
|
3680
3853
|
+ f"</tr></thead><tbody>{''.join(lim_rows)}</tbody></table>" if lim_rows
|
|
3681
3854
|
else "<p class='muted'>None known yet.</p>")
|
|
3682
|
-
+ "</
|
|
3855
|
+
+ "</details>"
|
|
3856
|
+
"<dialog id='q-models' aria-labelledby='q-models-h'><h3 id='q-models-h'></h3>"
|
|
3857
|
+
"<div class='sub q-reach' id='q-models-about'></div>"
|
|
3858
|
+
"<p class='sub' id='q-models-sub'></p><div class='scroll' id='q-models-list'></div>"
|
|
3859
|
+
"<div class='list-actions'><button class='btn' id='q-models-close'>Close</button></div></dialog>"
|
|
3860
|
+
f"<script type='application/json' id='quota-data'>{data}</script>")
|
|
3683
3861
|
|
|
3684
3862
|
|
|
3685
3863
|
def _log_rows(path: pathlib.Path, limit: int = 200) -> list[str]:
|
|
@@ -3751,6 +3929,14 @@ RELEASE, RELEASE_DATE, COMMIT = version()
|
|
|
3751
3929
|
VERSION = f"{RELEASE} {RELEASE_DATE}".strip() # the X-Benchwarmers-Version header
|
|
3752
3930
|
|
|
3753
3931
|
|
|
3932
|
+
def on_disk() -> tuple[str, str]:
|
|
3933
|
+
"""(running, on disk): what this process started from and what a restart
|
|
3934
|
+
would start, by commit, or by the VERSION file where there is no git."""
|
|
3935
|
+
if COMMIT:
|
|
3936
|
+
return COMMIT, _git("log", "-1", "--format=%h") or COMMIT
|
|
3937
|
+
return RELEASE, read(ROOT / "VERSION").strip()
|
|
3938
|
+
|
|
3939
|
+
|
|
3754
3940
|
def info_page() -> str:
|
|
3755
3941
|
import scheduler
|
|
3756
3942
|
import client as model_client
|
|
@@ -3859,6 +4045,7 @@ def editor() -> str:
|
|
|
3859
4045
|
# The page's own confirm (bench.js ask): window.confirm can be blocked.
|
|
3860
4046
|
"<dialog id='ask-dialog' aria-labelledby='ask-text'>"
|
|
3861
4047
|
"<p id='ask-text' style='white-space:pre-line'></p>"
|
|
4048
|
+
f"<div id='ask-onboard' hidden>{onboard_choice('ask')}</div>" # Discover's Add only
|
|
3862
4049
|
"<div class='actions'><button class='btn' id='ask-no' type='button'>Cancel</button>"
|
|
3863
4050
|
"<button class='btn primary' id='ask-yes' type='button'>OK</button></div></dialog>"
|
|
3864
4051
|
# onboarding
|
|
@@ -3876,13 +4063,7 @@ def editor() -> str:
|
|
|
3876
4063
|
" <button class='linkish' type='button' id='o-refresh'>Refresh</button></p>"
|
|
3877
4064
|
"<div class='picker' id='o-picker' role='group' aria-label='Served models'></div>"
|
|
3878
4065
|
# serve.ONBOARD_MODES: how much each onboarding spends.
|
|
3879
|
-
"
|
|
3880
|
-
"<option value='full'>Full: probe, then the suite now</option>"
|
|
3881
|
-
"<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
|
|
3882
|
-
"<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
|
|
3883
|
-
" (metered, capped or trial keys)</option></select></label>"
|
|
3884
|
-
"<label class='check'><input type='checkbox' id='o-vision'> Vision model: a seat only for questions"
|
|
3885
|
-
" about images, and its suite is the vision tasks</label>"
|
|
4066
|
+
f"{onboard_choice('o')}"
|
|
3886
4067
|
"</div>"
|
|
3887
4068
|
"<div id='o-run' hidden><p class='served' id='o-result'></p><ol class='steps' id='o-steps'></ol>"
|
|
3888
4069
|
"<details><summary class='muted'>Full log</summary><pre class='log' id='o-log'></pre></details></div>"
|
|
@@ -4122,7 +4303,13 @@ class Handler(gateway.Handler):
|
|
|
4122
4303
|
"""
|
|
4123
4304
|
path = self.path.split("?", 1)[0]
|
|
4124
4305
|
if guard.gateway_route(path):
|
|
4125
|
-
|
|
4306
|
+
with _answering_lock:
|
|
4307
|
+
_answering[0] += 1 # restart() waits for these
|
|
4308
|
+
try:
|
|
4309
|
+
return super().do_POST()
|
|
4310
|
+
finally:
|
|
4311
|
+
with _answering_lock:
|
|
4312
|
+
_answering[0] -= 1
|
|
4126
4313
|
if path == "/login":
|
|
4127
4314
|
return self._login()
|
|
4128
4315
|
ops = {"/api/model": lambda p: change_model(str(p.get("alias", "")),
|
|
@@ -4134,7 +4321,9 @@ class Handler(gateway.Handler):
|
|
|
4134
4321
|
"/api/retry": lambda p: retry_model(str(p.get("alias") or "")),
|
|
4135
4322
|
"/api/discover/add": lambda p: discover_add(str(p.get("connection") or ""),
|
|
4136
4323
|
str(p.get("model") or ""),
|
|
4137
|
-
endpoint=str(p.get("endpoint") or "")
|
|
4324
|
+
endpoint=str(p.get("endpoint") or ""),
|
|
4325
|
+
mode=str(p.get("mode") or "full"),
|
|
4326
|
+
vision=p.get("vision") is True),
|
|
4138
4327
|
"/api/onboard": lambda p: start_onboard(str(p.get("served", "")),
|
|
4139
4328
|
str(p.get("endpoint", "")),
|
|
4140
4329
|
mode=str(p.get("mode") or "full"),
|
|
@@ -4179,7 +4368,12 @@ class Handler(gateway.Handler):
|
|
|
4179
4368
|
"/api/gateway-key": lambda p: make_gateway_key(str(p.get("name") or ""), p.get("collections"),
|
|
4180
4369
|
p.get("rpm")),
|
|
4181
4370
|
"/api/gateway-key/remove": lambda p: revoke_gateway_key(str(p.get("name") or "")),
|
|
4182
|
-
"/api/theme": lambda p: save_theme(str(p.get("theme") or ""))
|
|
4371
|
+
"/api/theme": lambda p: save_theme(str(p.get("theme") or "")),
|
|
4372
|
+
"/api/quota/refresh": lambda p: quota_op("refresh", str(p.get("tag") or "")),
|
|
4373
|
+
"/api/quota/about": lambda p: quota_op("about", str(p.get("tag") or "")),
|
|
4374
|
+
"/api/alerts/dismiss": lambda p: dismiss_alert(str(p.get("key") or "")),
|
|
4375
|
+
"/api/quota/order": lambda p: quota_op("order", p.get("order")),
|
|
4376
|
+
"/api/restart": lambda p: restart(p.get("force") is True)}
|
|
4183
4377
|
# SAME ORIGIN ONLY. Any page open in the browser can POST a plain form
|
|
4184
4378
|
# to 127.0.0.1, and this used to parse whatever arrived as JSON -- so a
|
|
4185
4379
|
# website could retire models, and now could set a key. The checks are
|
|
@@ -4276,6 +4470,19 @@ class Handler(gateway.Handler):
|
|
|
4276
4470
|
|
|
4277
4471
|
|
|
4278
4472
|
if __name__ == "__main__":
|
|
4473
|
+
if os.environ.get("BENCH_SERVED") != "1":
|
|
4474
|
+
# The dashboard runs as this process's child, so Restart (restart())
|
|
4475
|
+
# can end it and have it started again on the code now on disk,
|
|
4476
|
+
# whatever started serve.py. The child ends with this one (_follow_parent).
|
|
4477
|
+
env = {**os.environ, "BENCH_SERVED": "1", "BENCH_PARENT": str(os.getpid())}
|
|
4478
|
+
try:
|
|
4479
|
+
while (code := subprocess.call([sys.executable, os.path.abspath(__file__), *sys.argv[1:]],
|
|
4480
|
+
env=env, stdin=subprocess.DEVNULL)) == RESTART:
|
|
4481
|
+
print("Benchwarmers: restarting on the code now on disk", flush=True)
|
|
4482
|
+
except KeyboardInterrupt:
|
|
4483
|
+
code = 0
|
|
4484
|
+
sys.exit(code)
|
|
4485
|
+
threading.Thread(target=_follow_parent, daemon=True).start()
|
|
4279
4486
|
address = guard.bind()
|
|
4280
4487
|
guard.startup(address) # beyond this machine, only with an admin token
|
|
4281
4488
|
shown = "localhost" if address == "127.0.0.1" else address
|
|
@@ -4284,4 +4491,6 @@ if __name__ == "__main__":
|
|
|
4284
4491
|
paths.make() # a fresh data folder's layout, before any writer
|
|
4285
4492
|
import scheduler
|
|
4286
4493
|
scheduler.start() # off unless config/scheduler.json says otherwise
|
|
4287
|
-
ThreadingHTTPServer((address, PORT), Handler)
|
|
4494
|
+
SERVER = ThreadingHTTPServer((address, PORT), Handler)
|
|
4495
|
+
SERVER.serve_forever() # returns only when restart() shuts it down
|
|
4496
|
+
sys.exit(RESTART)
|