@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.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/AGENTS.md +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +170 -44
- package/ui/bench.js +6 -3
package/serve.py
CHANGED
|
@@ -683,7 +683,7 @@ def retry_model(alias: str, who: str = "ui") -> dict:
|
|
|
683
683
|
with (DATA / "perf" / "history.csv").open("a", encoding="utf-8", newline="") as fh:
|
|
684
684
|
csv.writer(fh).writerow([datetime.datetime.now().isoformat(timespec="seconds"), alias, "retry", 1,
|
|
685
685
|
f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}",
|
|
686
|
-
0.0, 1, 0, 0, 0, "", "retry from the dashboard"])
|
|
686
|
+
0.0, 1, 0, 0, 0, "", "retry from the dashboard", ""])
|
|
687
687
|
import alerts as _alerts
|
|
688
688
|
served = {a: s for s, a in names.items()}.get(alias, "")
|
|
689
689
|
# Exactly this model's: auto_retire.py raises model:pending:<served> with its alias.
|
|
@@ -963,7 +963,7 @@ def free_name(stem: str) -> str:
|
|
|
963
963
|
|
|
964
964
|
def save_endpoint(name: str, base: str, secret: str, who: str = "ui", extra: dict | None = None,
|
|
965
965
|
new: bool = False, kind: str = "", hub: str = "", admin: str = "",
|
|
966
|
-
test_only: bool = False) -> dict:
|
|
966
|
+
test_only: bool = False, api: str = "") -> dict:
|
|
967
967
|
"""Add an endpoint, or change one's key. Tested before anything is saved;
|
|
968
968
|
`test_only` (the form's Test button) stops there and saves nothing.
|
|
969
969
|
|
|
@@ -980,11 +980,16 @@ def save_endpoint(name: str, base: str, secret: str, who: str = "ui", extra: dic
|
|
|
980
980
|
key where it is not the API key (LiteLLM's master key, CLI Proxy API's
|
|
981
981
|
management key), is kept beside it under endpoints.admin_name; nothing
|
|
982
982
|
tests it here, since a refused CLI Proxy API key counts toward a ban.
|
|
983
|
+
`api` (endpoints.APIS), on a new endpoint: what it speaks, when not chat
|
|
984
|
+
completions; Anthropic's and Gemini's native APIs take their own key
|
|
985
|
+
header, which the test sends too.
|
|
983
986
|
"""
|
|
984
987
|
name, base, secret = (name or "").strip().lower(), (base or "").strip().rstrip("/"), (secret or "").strip()
|
|
985
|
-
kind, hub, admin = (kind or "").strip(), (hub or "").strip(), (admin or "").strip()
|
|
988
|
+
kind, hub, admin, api = (kind or "").strip(), (hub or "").strip(), (admin or "").strip(), (api or "").strip()
|
|
986
989
|
data = endpoints.load()
|
|
987
990
|
errors = []
|
|
991
|
+
if api and (api not in endpoints.APIS or api == "responses"): # responses: Connect sets it, with its sign-in
|
|
992
|
+
errors.append(f"api: one of {', '.join(a for a in endpoints.APIS if a != 'responses')}")
|
|
988
993
|
if kind and kind not in endpoints.KINDS:
|
|
989
994
|
errors.append(f"kind: one of {', '.join(endpoints.KINDS)}")
|
|
990
995
|
elif kind == "hub" and hub not in endpoints.HUBS:
|
|
@@ -1011,6 +1016,8 @@ def save_endpoint(name: str, base: str, secret: str, who: str = "ui", extra: dic
|
|
|
1011
1016
|
# with its stale refresh token and overwrite the typed key.
|
|
1012
1017
|
entry.pop("connect", None)
|
|
1013
1018
|
entry.update(extra, base=base)
|
|
1019
|
+
if api and new:
|
|
1020
|
+
entry.pop("api", None) if api == "chat" else entry.update(api=api)
|
|
1014
1021
|
if kind:
|
|
1015
1022
|
entry.pop("hub", None)
|
|
1016
1023
|
entry.update(kind=kind, **({"hub": hub} if kind == "hub" else {}))
|
|
@@ -1244,10 +1251,11 @@ def connect_page(out: dict) -> str:
|
|
|
1244
1251
|
f"<p><a href='{html.escape(home)}'>Back to Benchwarmers</a>. You can close this tab.</p>")
|
|
1245
1252
|
|
|
1246
1253
|
|
|
1247
|
-
def _clean_limits(raw) -> tuple[dict, list[str]]:
|
|
1248
|
-
"""rpm/rpd/tpm/tpd as positive whole numbers;
|
|
1254
|
+
def _clean_limits(raw, keys=catalog.LIMIT_KEYS) -> tuple[dict, list[str]]:
|
|
1255
|
+
"""rpm/rpd/tpm/tpd (and a connection's rpmo) as positive whole numbers;
|
|
1256
|
+
blank clears one."""
|
|
1249
1257
|
out, errors = {}, []
|
|
1250
|
-
for k in
|
|
1258
|
+
for k in keys:
|
|
1251
1259
|
v = (raw or {}).get(k)
|
|
1252
1260
|
if v in (None, ""):
|
|
1253
1261
|
continue
|
|
@@ -1355,7 +1363,7 @@ def save_connection_limits(connection: str, limits: dict, who: str = "ui") -> di
|
|
|
1355
1363
|
if connection not in known:
|
|
1356
1364
|
return {"ok": False, "errors": [f"{connection!r} is not a connection any model uses;"
|
|
1357
1365
|
f" known: {', '.join(sorted(known))}"]}
|
|
1358
|
-
clean, errors = _clean_limits(limits)
|
|
1366
|
+
clean, errors = _clean_limits(limits, catalog.CONNECTION_LIMIT_KEYS)
|
|
1359
1367
|
if errors:
|
|
1360
1368
|
return {"ok": False, "errors": errors}
|
|
1361
1369
|
data = catalog._read(catalog.LIMITS)
|
|
@@ -1376,7 +1384,8 @@ def connections_view() -> dict:
|
|
|
1376
1384
|
for a in _chat_aliases():
|
|
1377
1385
|
by.setdefault(catalog.connection_of(a), []).append(a)
|
|
1378
1386
|
lim = catalog.connection_limits()
|
|
1379
|
-
return {"ok": True, "errors": [], "keys": list(catalog.
|
|
1387
|
+
return {"ok": True, "errors": [], "keys": list(catalog.CONNECTION_LIMIT_KEYS),
|
|
1388
|
+
"labels": {k: catalog.LIMIT_LABELS[k] for k in catalog.CONNECTION_LIMIT_KEYS},
|
|
1380
1389
|
"connections": [{"name": c, "models": sorted(m), "limits": lim.get(c, {})}
|
|
1381
1390
|
for c, m in sorted(by.items())]}
|
|
1382
1391
|
|
|
@@ -1511,7 +1520,15 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
|
|
|
1511
1520
|
return {"ok": True, "errors": [], "changed": ["config/gateway.json"]}
|
|
1512
1521
|
|
|
1513
1522
|
|
|
1514
|
-
|
|
1523
|
+
# How much an onboarding spends. Full: the 4-call probe, then the suite now.
|
|
1524
|
+
# 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. Quick + ad
|
|
1526
|
+
# hoc: the probe only, and the bench never calls it on its own: the only one
|
|
1527
|
+
# that saves calls, for a metered, capped or trial key.
|
|
1528
|
+
ONBOARD_MODES = {"full": [], "quick": ["--skip-suite"], "quick-adhoc": ["--skip-suite", "--ad-hoc"]}
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str = "full") -> dict:
|
|
1515
1532
|
"""Kick off `scripts/onboard.py` for a served model, detached, logging to runs/.
|
|
1516
1533
|
|
|
1517
1534
|
The script does its own checks in the right order (is it served, derive
|
|
@@ -1521,12 +1538,12 @@ def start_onboard(served: str, endpoint: str = "", who: str = "ui") -> dict:
|
|
|
1521
1538
|
hands the rest to the script. It runs for minutes and spends calls, so it
|
|
1522
1539
|
runs in the background and the page tails its log.
|
|
1523
1540
|
"""
|
|
1524
|
-
return _spawn_onboard(served, endpoint, who)[0]
|
|
1541
|
+
return _spawn_onboard(served, endpoint, who, mode)[0]
|
|
1525
1542
|
|
|
1526
1543
|
|
|
1527
|
-
def _onboard_refusal(served: str, endpoint: str) -> list[str]:
|
|
1544
|
+
def _onboard_refusal(served: str, endpoint: str, mode: str = "full") -> list[str]:
|
|
1528
1545
|
"""What start_onboard refuses without a call (its docstring)."""
|
|
1529
|
-
errors = []
|
|
1546
|
+
errors = [] if mode in ONBOARD_MODES else [f"mode must be one of {', '.join(ONBOARD_MODES)}; got {mode!r}"]
|
|
1530
1547
|
# It goes into a shell command line below, so only the characters model
|
|
1531
1548
|
# ids actually use -- not merely "no spaces".
|
|
1532
1549
|
if not re.fullmatch(r"[\w./:@+-]{1,200}", served):
|
|
@@ -1549,18 +1566,20 @@ def _onboard_refusal(served: str, endpoint: str) -> list[str]:
|
|
|
1549
1566
|
return errors
|
|
1550
1567
|
|
|
1551
1568
|
|
|
1552
|
-
def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui"
|
|
1569
|
+
def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui",
|
|
1570
|
+
mode: str = "full") -> tuple[dict, subprocess.Popen | None]:
|
|
1553
1571
|
"""start_onboard's result, and the process (None when refused) for a caller that waits on it."""
|
|
1554
1572
|
served = (served or "").strip()
|
|
1555
1573
|
endpoint = (endpoint or "").strip() or endpoints.default()
|
|
1556
|
-
errors = _onboard_refusal(served, endpoint)
|
|
1574
|
+
errors = _onboard_refusal(served, endpoint, mode)
|
|
1557
1575
|
if errors:
|
|
1558
1576
|
return {"ok": False, "errors": errors}, None
|
|
1559
1577
|
stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
|
|
1560
1578
|
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", served)
|
|
1561
1579
|
log = DATA / "runs" / f"onboard-{stamp}-{safe}.log"
|
|
1562
1580
|
from run import bench_python
|
|
1563
|
-
cmd = [bench_python(), str(ROOT / "scripts" / "onboard.py"), served, "--endpoint", endpoint
|
|
1581
|
+
cmd = [bench_python(), str(ROOT / "scripts" / "onboard.py"), served, "--endpoint", endpoint,
|
|
1582
|
+
*ONBOARD_MODES[mode]]
|
|
1564
1583
|
# The log handle goes straight to the child, unbuffered, so the page sees
|
|
1565
1584
|
# each step as it prints. The old `cmd /c ... > log & echo` wrapper with
|
|
1566
1585
|
# DETACHED_PROCESS sent the output to a console window instead, so every
|
|
@@ -1583,7 +1602,7 @@ def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui") -> tuple[di
|
|
|
1583
1602
|
|
|
1584
1603
|
threading.Thread(target=sentinel, daemon=True).start()
|
|
1585
1604
|
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
1586
|
-
who, "", "onboard", None, served)
|
|
1605
|
+
who, "", "onboard" + ("" if mode == "full" else f" ({mode})"), None, served)
|
|
1587
1606
|
return {"ok": True, "errors": [], "log": log.name}, proc
|
|
1588
1607
|
|
|
1589
1608
|
|
|
@@ -1627,20 +1646,21 @@ def queue_onboard(items, who: str = "ui") -> dict:
|
|
|
1627
1646
|
it = it if isinstance(it, dict) else {}
|
|
1628
1647
|
served = str(it.get("served") or "").strip()
|
|
1629
1648
|
endpoint = str(it.get("endpoint") or "").strip() or endpoints.default()
|
|
1630
|
-
|
|
1631
|
-
|
|
1649
|
+
mode = str(it.get("mode") or "full")
|
|
1650
|
+
errors += _onboard_refusal(served, endpoint, mode)
|
|
1651
|
+
picked.append((served, endpoint, mode))
|
|
1632
1652
|
with _OQ_LOCK:
|
|
1633
1653
|
waiting = {(i["served"], i["endpoint"]) for i in _OQ["items"] if i["state"] in ("queued", "running")}
|
|
1634
|
-
errors += [f"{s} from {e} is already in the queue" for s, e in picked if (s, e) in waiting]
|
|
1635
|
-
if len(
|
|
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):
|
|
1636
1656
|
errors.append("a model is picked twice")
|
|
1637
1657
|
if errors:
|
|
1638
1658
|
return {"ok": False, "errors": errors}
|
|
1639
1659
|
if _OQ["worker"] is None:
|
|
1640
1660
|
_OQ["items"] = []
|
|
1641
1661
|
_OQ["stop"] = False # added after a Stop: these run
|
|
1642
|
-
_OQ["items"] += [{"served": s, "endpoint": e, "state": "queued", "alias": None, "log": None,
|
|
1643
|
-
"exit": None, "note": ""} for s, e in picked]
|
|
1662
|
+
_OQ["items"] += [{"served": s, "endpoint": e, "mode": m, "state": "queued", "alias": None, "log": None,
|
|
1663
|
+
"exit": None, "note": ""} for s, e, m in picked]
|
|
1644
1664
|
if _OQ["worker"] is None:
|
|
1645
1665
|
_OQ["worker"] = threading.Thread(target=_onboard_worker, args=(who,), daemon=True)
|
|
1646
1666
|
_OQ["worker"].start()
|
|
@@ -1692,7 +1712,7 @@ def _onboard_worker(who: str) -> None:
|
|
|
1692
1712
|
return
|
|
1693
1713
|
nxt["state"] = "running"
|
|
1694
1714
|
try:
|
|
1695
|
-
res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who)
|
|
1715
|
+
res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who, nxt.get("mode", "full"))
|
|
1696
1716
|
if proc is None: # refused now: the bench changed since it was queued
|
|
1697
1717
|
state, note, code = "failed", "; ".join(res["errors"]), None
|
|
1698
1718
|
else:
|
|
@@ -2398,7 +2418,7 @@ def perf_card() -> str:
|
|
|
2398
2418
|
if r["model"] in live}
|
|
2399
2419
|
widest = max((float(r["p50_s"]) for r in latest.values()), default=1) or 1
|
|
2400
2420
|
body = ["<table><thead><tr>" + th("Model") + th("Prompt") + th("p50", True)
|
|
2401
|
-
+ th("p95", True) + th("ch/s", True) + th("Outcomes")
|
|
2421
|
+
+ th("p95", True) + th("ch/s", True) + th("tok/s", True) + th("Outcomes")
|
|
2402
2422
|
+ "<th>p50, relative</th></tr></thead><tbody>"]
|
|
2403
2423
|
for (model, prompt), row in sorted(latest.items()):
|
|
2404
2424
|
p50 = float(row["p50_s"])
|
|
@@ -2411,6 +2431,7 @@ def perf_card() -> str:
|
|
|
2411
2431
|
f"<td class='num'>{p50:.1f}s</td>"
|
|
2412
2432
|
f"<td class='num'>{float(row['p95_s']):.1f}s</td>"
|
|
2413
2433
|
f"<td class='num'>{esc(row['chars_per_s'])}</td>"
|
|
2434
|
+
f"<td class='num'>{esc(row.get('tokens_per_s') or '—')}</td>"
|
|
2414
2435
|
f"<td class='{'err' if bad else ''}'>{esc(oc)}</td>"
|
|
2415
2436
|
f"<td><span class='meter' style='width:{width}px' "
|
|
2416
2437
|
f"title='p50 {p50:.1f}s'><span style='width:100%'></span></span></td></tr>")
|
|
@@ -2428,6 +2449,9 @@ def perf_card() -> str:
|
|
|
2428
2449
|
questions.</dd>
|
|
2429
2450
|
<dt>ch/s</dt><dd>Characters per second, from the large prompt only. A
|
|
2430
2451
|
proxy for tokens/s, affected by how verbose a model is.</dd>
|
|
2452
|
+
<dt>tok/s</dt><dd>Output tokens per second from the provider's own count, first token to
|
|
2453
|
+
last, large prompt only. Only where the built-in client made the call and the provider
|
|
2454
|
+
reported usage; blank otherwise, never estimated.</dd>
|
|
2431
2455
|
<dt>Outcomes</dt><dd>answered / refused / error / timeout.</dd>
|
|
2432
2456
|
<dt>Weight it LEAST</dt><dd>Seconds are a wash. Speed only vetoes a model
|
|
2433
2457
|
when it is slow enough to change how you work.</dd>
|
|
@@ -2549,8 +2573,9 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2549
2573
|
explorers, then the merger if the reply was merged. Green answered, amber came back empty, red failed. ✓ judged
|
|
2550
2574
|
useful, ✗ judged not useful; no mark means the verdict is still owed.
|
|
2551
2575
|
Hover a chip for its seat, timing and call id; click it to read the answer
|
|
2552
|
-
and record a verdict. A grey chip with a dash is a member not asked
|
|
2553
|
-
|
|
2576
|
+
and record a verdict. A grey chip with a dash is a member not asked: the request
|
|
2577
|
+
carried images it cannot take, or it is a vision specialist and the request
|
|
2578
|
+
carried none. Hover it for why.</dd>
|
|
2554
2579
|
<dt>Asked by</dt><dd>The gateway key that made the request, or <code>local</code>
|
|
2555
2580
|
when no key is asked for.</dd>
|
|
2556
2581
|
<dt>Owed</dt><dd>Seats without a verdict. Each is recorded as an unjudged
|
|
@@ -2562,7 +2587,49 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2562
2587
|
</dl>"""
|
|
2563
2588
|
return card("Gateway", f"The last {len(records)} bench-gateway requests:"
|
|
2564
2589
|
f" {seats_total} seats, {owed} verdict{'s' * (owed != 1)} owed.",
|
|
2565
|
-
"".join(body), key)
|
|
2590
|
+
"".join(body) + route_table(judged), key)
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
def route_table(judged: dict, limit: int = 30) -> str:
|
|
2594
|
+
"""Route-mode conversations (runs/gateway/routes.jsonl, a line a turn),
|
|
2595
|
+
one row each, apart from the panels: an agent turn is not a seat, and a
|
|
2596
|
+
conversation's verdict is optional, so none of this counts as Owed.
|
|
2597
|
+
ponytail: reads the whole log; read its tail once it grows large."""
|
|
2598
|
+
convs: dict[str, dict] = {}
|
|
2599
|
+
for line in read(DATA / "runs" / "gateway" / "routes.jsonl").splitlines():
|
|
2600
|
+
try:
|
|
2601
|
+
t = json.loads(line)
|
|
2602
|
+
except ValueError:
|
|
2603
|
+
continue
|
|
2604
|
+
c = convs.setdefault(str(t.get("conversation", "")), {
|
|
2605
|
+
"at": t.get("at", ""), "model": t.get("model", ""), "key": t.get("key", ""), "task": t.get("task", ""),
|
|
2606
|
+
"turns": 0, "tools": 0, "failovers": 0, "models": {}})
|
|
2607
|
+
c["turns"] += 1
|
|
2608
|
+
c["tools"] += t.get("tool_calls") or 0
|
|
2609
|
+
c["failovers"] += len(t.get("tried") or [])
|
|
2610
|
+
c.update(last=t.get("at", ""), status=t.get("status", ""), error=t.get("error", ""))
|
|
2611
|
+
if t.get("alias"):
|
|
2612
|
+
c["models"].setdefault(t["alias"], t.get("call", ""))
|
|
2613
|
+
if not convs:
|
|
2614
|
+
return ""
|
|
2615
|
+
rows = ["<h3>Route conversations</h3><table><thead><tr>", th("When"), th("Collection"), th("Asked by"),
|
|
2616
|
+
th("First message"), th("Answered by"), th("Turns", True), th("Tool calls", True),
|
|
2617
|
+
th("Failovers", True), th("Last turn"), "</tr></thead><tbody>"]
|
|
2618
|
+
for conv, c in sorted(convs.items(), key=lambda kv: kv[1]["last"], reverse=True)[:limit]:
|
|
2619
|
+
chips = " ".join(f"<span class='chip' title='{esc(call + ' · ' + {True: 'useful', False: 'not useful', None: 'no verdict (optional)'}[judged.get(call)])}'>"
|
|
2620
|
+
f"{esc(alias)}{ {True: ' ✓', False: ' ✗', None: ''}[judged.get(call)]}</span>"
|
|
2621
|
+
for alias, call in c["models"].items())
|
|
2622
|
+
css = {"ok": "ok", "empty": "warn", "error": "err"}.get(c["status"], "")
|
|
2623
|
+
rows.append(
|
|
2624
|
+
f"<tr><td class='mono' data-sort='{esc(c['at'])}' title='{esc(conv)}'>{esc(c['at'][:16].replace('T', ' '))}</td>"
|
|
2625
|
+
f"<td class='mono'>{esc(c['model'])}</td><td class='mono'>{esc(c['key'] or '—')}</td>"
|
|
2626
|
+
f"<td>{esc(c['task'][:60])}</td><td>{chips or '—'}</td><td class='num'>{c['turns']}</td>"
|
|
2627
|
+
f"<td class='num'>{c['tools']}</td><td class='num{' muted' if not c['failovers'] else ''}'>{c['failovers']}</td>"
|
|
2628
|
+
f"<td><span class='chip {css}' title='{esc(c['error'])}'>{esc(c['status'])}</span></td></tr>")
|
|
2629
|
+
rows.append("</tbody></table><p class='muted'>Each conversation is one unjudged offload per model that"
|
|
2630
|
+
" answered it, under the call id on its chip; a verdict is optional:"
|
|
2631
|
+
" <code>python metrics/offload.py verdict <call id> --useful</code>.</p>")
|
|
2632
|
+
return "".join(rows)
|
|
2566
2633
|
|
|
2567
2634
|
|
|
2568
2635
|
def gateway_seat(call: str) -> dict:
|
|
@@ -2773,11 +2840,13 @@ def collections_tab() -> str:
|
|
|
2773
2840
|
"<p class='rules'>Which models may fill the explorer seats. Automatic asks a decision"
|
|
2774
2841
|
" model to label each prompt when one is onboarded, else general.</p>"
|
|
2775
2842
|
"<label class='field' for='c-reply'>Reply</label><select id='c-reply'>"
|
|
2776
|
-
"<option value='all'>every answer</option><option value='merged'>merged</option
|
|
2843
|
+
"<option value='all'>every answer</option><option value='merged'>merged</option>"
|
|
2844
|
+
"<option value='route'>route (one member, for agents)</option></select>"
|
|
2777
2845
|
"<p class='rules'>Merged: after the panel, one model writes a single answer saying where the"
|
|
2778
2846
|
" seats agreed and who said what, for a chat UI that shows one answer; every seat is still"
|
|
2779
|
-
" called and recorded.
|
|
2780
|
-
" or
|
|
2847
|
+
" called and recorded. Route: no panel; one member, picked by its record, gets the request"
|
|
2848
|
+
" with its tools, for an agent such as Claude Code or Codex. A caller can ask for any with"
|
|
2849
|
+
" <code>bench/<name>:merged</code>, <code>:all</code> or <code>:route</code>.</p>"
|
|
2781
2850
|
# Only a merged reply has a merger; the choice is kept while hidden.
|
|
2782
2851
|
"<div id='c-merger-row' hidden><label class='field' for='c-merger'>Merger</label>"
|
|
2783
2852
|
"<select id='c-merger'></select>"
|
|
@@ -3219,7 +3288,9 @@ def endpoints_page() -> str:
|
|
|
3219
3288
|
"<div class='panel form-panel'><h2>Rate limits per connection</h2>"
|
|
3220
3289
|
"<p class='sub'>What a key behind each connection allows, from the provider's docs or"
|
|
3221
3290
|
" freellmapi.co/models. Models on that connection share it; a model's own limits (its"
|
|
3222
|
-
" popup) win, and limits the bench learns from errors only ever lower these
|
|
3291
|
+
" popup) win, and limits the bench learns from errors only ever lower these. rpmo is calls"
|
|
3292
|
+
" a calendar month (UTC), for a trial key's monthly cap. The built-in client keeps to them:"
|
|
3293
|
+
" when one is used up it waits if the call can still finish in time, else it does not send.</p>"
|
|
3223
3294
|
"<div id='l-list'></div><div class='errors' id='l-errors' role='alert'></div>"
|
|
3224
3295
|
"<div class='done' id='l-done'></div></div>")
|
|
3225
3296
|
|
|
@@ -3270,11 +3341,37 @@ def models_page() -> str:
|
|
|
3270
3341
|
|
|
3271
3342
|
|
|
3272
3343
|
def quotas_page() -> str:
|
|
3273
|
-
"""What limits each connection,
|
|
3274
|
-
|
|
3275
|
-
|
|
3344
|
+
"""What limits each connection, who is resting after one, what providers
|
|
3345
|
+
last said was left (the built-in client's quota records, metrics/quota.py)
|
|
3346
|
+
and what the bench itself sent (its usage ledger, metrics/ledger.py)."""
|
|
3276
3347
|
import learn
|
|
3348
|
+
import ledger
|
|
3349
|
+
import quota
|
|
3277
3350
|
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
|
+
t = now.timestamp()
|
|
3365
|
+
sent: dict = {}
|
|
3366
|
+
for row in ledger.rows(min(t - 86400, ledger.month_start(t))):
|
|
3367
|
+
s = sent.setdefault(row.get("connection") or "?", [0, 0, 0, 0])
|
|
3368
|
+
n, tok = row.get("attempts") or 0, (row.get("tokens_in") or 0) + (row.get("tokens_out") or 0)
|
|
3369
|
+
if row["t"] >= t - 86400:
|
|
3370
|
+
s[0], s[1] = s[0] + n, s[1] + tok
|
|
3371
|
+
if row["t"] >= ledger.month_start(t):
|
|
3372
|
+
s[2], s[3] = s[2] + n, s[3] + tok
|
|
3373
|
+
sent_rows = [f"<tr><td>{esc(c)}</td>" + "".join(f"<td class='num'>{v:,}</td>" for v in s) + "</tr>"
|
|
3374
|
+
for c, s in sorted(sent.items())]
|
|
3278
3375
|
cool_rows = []
|
|
3279
3376
|
for alias, c in sorted(learn.cooldowns().items(), key=lambda kv: kv[1].get("until", "")):
|
|
3280
3377
|
try:
|
|
@@ -3285,7 +3382,7 @@ def quotas_page() -> str:
|
|
|
3285
3382
|
f"<td class='num' data-sort='{left}'>{left} min</td>"
|
|
3286
3383
|
f"<td>{'stated by the provider' if c.get('stated') else 'default wait'}</td></tr>")
|
|
3287
3384
|
entered = catalog.connection_limits()
|
|
3288
|
-
keys = catalog.
|
|
3385
|
+
keys = catalog.CONNECTION_LIMIT_KEYS
|
|
3289
3386
|
lim_rows = [f"<tr><td>{esc(conn)}</td>" + "".join(
|
|
3290
3387
|
f"<td class='num'>{lim[k]:,}</td>" if lim.get(k) else "<td class='num muted'>—</td>" for k in keys)
|
|
3291
3388
|
+ "<td>you</td></tr>" for conn, lim in sorted(entered.items()) if any(lim.get(k) for k in keys)]
|
|
@@ -3300,10 +3397,23 @@ def quotas_page() -> str:
|
|
|
3300
3397
|
+ f"<td>learned {esc(ev.get('at', '')[:10])} ({esc(ev.get('where', ''))})</td></tr>")
|
|
3301
3398
|
return (
|
|
3302
3399
|
"<div class='panel'><h2>Quota management</h2><p class='sub'>What is left on each connection and"
|
|
3303
|
-
" when it resets,
|
|
3304
|
-
"
|
|
3305
|
-
"
|
|
3306
|
-
" in CLI Proxy API's own console.</p></div>"
|
|
3400
|
+
" when it resets, from the rate-limit headers and 429 replies the built-in client sees, and what"
|
|
3401
|
+
" the bench sent itself, from its usage ledger; who is resting after a rate limit, and every"
|
|
3402
|
+
" limit known. The built-in client keeps to all of these. Calls through the llm CLI are in"
|
|
3403
|
+
" neither record. Quotas of OAuth credentials stay in CLI Proxy API's own console.</p></div>"
|
|
3404
|
+
"<div class='panel'><h3 class='chart-head'>What providers said is left · " + str(len(rec_rows)) + "</h3>"
|
|
3405
|
+
+ ("<table><thead><tr>" + th("Connection") + th("Model") + th("Counts") + th("Left", True)
|
|
3406
|
+
+ th("Of", True) + th("Resets") + th("From") + f"</tr></thead><tbody>{''.join(rec_rows)}</tbody></table>"
|
|
3407
|
+
if rec_rows else "<p class='muted'>Nothing in force: no provider has stated a limit whose reset"
|
|
3408
|
+
" is still ahead.</p>")
|
|
3409
|
+
+ "</div>"
|
|
3410
|
+
"<div class='panel'><h3 class='chart-head'>What the bench sent</h3><p class='sub'>Requests"
|
|
3411
|
+
" (retries included) and tokens per connection, from the usage ledger.</p>"
|
|
3412
|
+
+ ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
|
|
3413
|
+
+ th("Requests, this month", True) + th("Tokens, this month", True)
|
|
3414
|
+
+ f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
|
|
3415
|
+
if sent_rows else "<p class='muted'>No call in the ledger this month.</p>")
|
|
3416
|
+
+ "</div>"
|
|
3307
3417
|
"<div class='panel'><h3 class='chart-head'>Resting after a rate limit · " + str(len(cool_rows)) + "</h3>"
|
|
3308
3418
|
+ ("<table><thead><tr>" + th("Model") + th("Connection") + th("Back in", True) + th("Wait")
|
|
3309
3419
|
+ f"</tr></thead><tbody>{''.join(cool_rows)}</tbody></table>" if cool_rows
|
|
@@ -3476,8 +3586,10 @@ def editor() -> str:
|
|
|
3476
3586
|
"<div class='actions'><button class='btn' id='co-save' type='button'>Save options</button></div>"
|
|
3477
3587
|
"</details>"
|
|
3478
3588
|
"<label class='check'><input type='checkbox' id='m-adhoc'> Ad hoc only</label>"
|
|
3479
|
-
"<p class='rules'>
|
|
3480
|
-
"
|
|
3589
|
+
"<p class='rules'>Never chosen by the bench on its own: no explorer seat, no router"
|
|
3590
|
+
" pick, no scheduled suite, perf or availability runs. Still called whenever you choose"
|
|
3591
|
+
" it: as a collection member (or merger), with <code>with</code>, or with"
|
|
3592
|
+
" <code>--model</code>. For metered, capped or trial keys.</p>"
|
|
3481
3593
|
"</div>"
|
|
3482
3594
|
"<div class='errors' id='m-errors' role='alert'></div><div class='done' id='m-done'></div>"
|
|
3483
3595
|
"<div class='actions'><button class='btn' id='m-close'>Close</button>"
|
|
@@ -3509,6 +3621,12 @@ def editor() -> str:
|
|
|
3509
3621
|
"<input type='search' id='o-find' placeholder='Search models or connections'"
|
|
3510
3622
|
" aria-label='Search models'>"
|
|
3511
3623
|
"<div class='picker' id='o-picker' role='group' aria-label='Served models'></div>"
|
|
3624
|
+
# serve.ONBOARD_MODES: how much each onboarding spends.
|
|
3625
|
+
"<label class='check'>Onboard as <select id='o-mode'>"
|
|
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>"
|
|
3512
3630
|
"</div>"
|
|
3513
3631
|
"<div id='o-run' hidden><p class='served' id='o-result'></p><ol class='steps' id='o-steps'></ol>"
|
|
3514
3632
|
"<details><summary class='muted'>Full log</summary><pre class='log' id='o-log'></pre></details></div>"
|
|
@@ -3548,7 +3666,14 @@ def editor() -> str:
|
|
|
3548
3666
|
+ "</select><p class='rules'>Open WebUI's base is its <code>/api</code>, e.g."
|
|
3549
3667
|
" <code>http://localhost:3000/api</code>, with an admin's API key. LiteLLM's and CLI Proxy"
|
|
3550
3668
|
" API's are their <code>/v1</code>, e.g. <code>http://localhost:4000/v1</code> and"
|
|
3551
|
-
" <code>http://localhost:8317/v1</code>.</p></div
|
|
3669
|
+
" <code>http://localhost:8317/v1</code>.</p></div>"
|
|
3670
|
+
# What it speaks (endpoints.APIS): the built-in client builds the native requests.
|
|
3671
|
+
"<label class='field' for='e-api'>API</label><select id='e-api'>"
|
|
3672
|
+
+ "".join(f"<option value='{k}'>{esc(label)}</option>" for k, label in endpoints.APIS.items() if k != "responses")
|
|
3673
|
+
+ "</select><p class='rules'>Chat completions unless the provider has none for your key. Native"
|
|
3674
|
+
" Anthropic's base is <code>https://api.anthropic.com/v1</code>, Gemini's"
|
|
3675
|
+
" <code>https://generativelanguage.googleapis.com/v1beta</code>; their models are called by the"
|
|
3676
|
+
" built-in client, and route mode does not pick them.</p></div>"
|
|
3552
3677
|
"<label class='field' for='e-key'>API key</label>"
|
|
3553
3678
|
"<input id='e-key' type='password' autocomplete='off' placeholder='leave blank to keep the current one'>"
|
|
3554
3679
|
"<p class='rules'>Stored where <code>llm keys set</code> stores it, never in the"
|
|
@@ -3755,7 +3880,8 @@ class Handler(gateway.Handler):
|
|
|
3755
3880
|
str(p.get("model") or ""),
|
|
3756
3881
|
endpoint=str(p.get("endpoint") or "")),
|
|
3757
3882
|
"/api/onboard": lambda p: start_onboard(str(p.get("served", "")),
|
|
3758
|
-
str(p.get("endpoint", ""))
|
|
3883
|
+
str(p.get("endpoint", "")),
|
|
3884
|
+
mode=str(p.get("mode") or "full")),
|
|
3759
3885
|
"/api/onboard-queue": lambda p: queue_onboard(p.get("items")),
|
|
3760
3886
|
"/api/onboard-queue/stop": lambda p: stop_onboard_queue(),
|
|
3761
3887
|
"/api/onboard-queue/clear": lambda p: clear_onboard_queue(),
|
|
@@ -3765,7 +3891,7 @@ class Handler(gateway.Handler):
|
|
|
3765
3891
|
str(p.get("key", "")), new=bool(p.get("new")),
|
|
3766
3892
|
kind=str(p.get("kind") or ""), hub=str(p.get("hub") or ""),
|
|
3767
3893
|
admin=str(p.get("admin") or ""),
|
|
3768
|
-
test_only=bool(p.get("test"))),
|
|
3894
|
+
test_only=bool(p.get("test")), api=str(p.get("api") or "")),
|
|
3769
3895
|
"/api/endpoint/remove": lambda p: remove_endpoint(str(p.get("name", "")),
|
|
3770
3896
|
with_models=bool(p.get("with_models"))),
|
|
3771
3897
|
"/api/connect/start": lambda p: connect_start(str(p.get("provider") or ""),
|
package/ui/bench.js
CHANGED
|
@@ -573,7 +573,8 @@
|
|
|
573
573
|
// model at a time. The popup closes: the header's chip shows the batch, and
|
|
574
574
|
// its Details the queue. Refused, it stays open with the reasons.
|
|
575
575
|
o.start.addEventListener('click', function () {
|
|
576
|
-
var
|
|
576
|
+
var mode = $('o-mode').value;
|
|
577
|
+
var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode}, chosen[k]); });
|
|
577
578
|
if (!items.length) return;
|
|
578
579
|
o.errors.textContent = ''; o.start.disabled = o.dismiss.disabled = true; o.start.textContent = 'Starting…';
|
|
579
580
|
post('/api/onboard-queue', {items: items}).then(function (res) {
|
|
@@ -1016,7 +1017,7 @@
|
|
|
1016
1017
|
left.appendChild(el('div', {class: 'base'}, c.models.join(', ')));
|
|
1017
1018
|
var inputs = el('div', {class: 'limits'});
|
|
1018
1019
|
res.keys.forEach(function (k) {
|
|
1019
|
-
var lab = el('label', {}, k + ' ');
|
|
1020
|
+
var lab = el('label', {title: (res.labels || {})[k] || k}, k + ' ');
|
|
1020
1021
|
var box = el('input', {type: 'number', min: '1', 'data-k': k, placeholder: '—'});
|
|
1021
1022
|
if (c.limits[k]) box.value = c.limits[k];
|
|
1022
1023
|
lab.appendChild(box); inputs.appendChild(lab);
|
|
@@ -1092,7 +1093,9 @@
|
|
|
1092
1093
|
function keyBody() {
|
|
1093
1094
|
var body = {name: e.name.value, base: e.base.value, key: e.key.value, new: e.adding,
|
|
1094
1095
|
admin: $('e-admin-row').hidden ? '' : e.admin.value};
|
|
1095
|
-
if (e.adding) {
|
|
1096
|
+
if (e.adding) { // Change key keeps its kind and API
|
|
1097
|
+
body.kind = e.kind.value; body.hub = e.kind.value === 'hub' ? e.hub.value : ''; body.api = $('e-api').value;
|
|
1098
|
+
}
|
|
1096
1099
|
return body;
|
|
1097
1100
|
}
|
|
1098
1101
|
$('e-add').addEventListener('click', function () { openEndpoint('add'); });
|