@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/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; blank clears one."""
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 catalog.LIMIT_KEYS:
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.LIMIT_KEYS),
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
- def start_onboard(served: str, endpoint: str = "", who: str = "ui") -> dict:
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") -> tuple[dict, subprocess.Popen | None]:
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
- errors += _onboard_refusal(served, endpoint)
1631
- picked.append((served, endpoint))
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(set(picked)) < len(picked):
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 because the
2553
- request carried images it cannot take; hover it for why.</dd>
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 &lt;call id&gt; --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></select>"
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. A caller can ask for either with <code>bench/&lt;name&gt;:merged</code>"
2780
- " or <code>:all</code>.</p>"
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/&lt;name&gt;: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.</p>"
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, and who is resting after one. The live
3274
- numbers (what is left, when it resets) come with the built-in client's
3275
- quota records, RELEASE-PLAN Phase 2b."""
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.LIMIT_KEYS
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, read from provider endpoints, rate-limit headers and the bench's own call"
3304
- " ledger, arrives with the built-in client's quota records (RELEASE-PLAN Phase 2b). Until then:"
3305
- " who is resting after a rate limit, and every limit known. Quotas of OAuth credentials stay"
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'>For expensive or capped models: never seated automatically, never"
3480
- " swept, never probed daily. Used only when named.</p>"
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></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 items = Object.keys(chosen).map(function (k) { return chosen[k]; });
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) { body.kind = e.kind.value; body.hub = e.kind.value === 'hub' ? e.hub.value : ''; } // Change key keeps its kind
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'); });