@wendellresearch-ai/benchwarmers 0.5.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/serve.py CHANGED
@@ -686,6 +686,17 @@ def remove_model(alias: str, reason: str = "", who: str = "ui") -> dict:
686
686
  return {"ok": True, "errors": [], "changed": changed, "sync": _sync_cli()}
687
687
 
688
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
+
689
700
  def retry_model(alias: str, who: str = "ui") -> dict:
690
701
  """One fresh call to a model on a retiring alert. An answer is written to
691
702
  perf/history.csv as evidence (scripts/auto_retire.py reads it, so the
@@ -2038,6 +2049,8 @@ def problems() -> str:
2038
2049
  acts.append(f"<button class='btn small' data-act='open' data-alias='{esc(alias)}'>Open</button>")
2039
2050
  acts.append(f"<button class='btn small danger' data-act='retire' data-alias='{esc(alias)}'"
2040
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>")
2041
2054
  items.append((key, a.get("kind", "?"), KIND.get(a.get("kind"), "warn"),
2042
2055
  a.get("message", ""), why, "".join(acts)))
2043
2056
  cutoff = (today - datetime.timedelta(days=7)).isoformat()
@@ -3170,8 +3183,10 @@ def collections_tab() -> str:
3170
3183
  # The panel as it will sit, drawn as the Collections list draws it
3171
3184
  # (bench.js recount): a seat per ticked member, explorer seats dashed.
3172
3185
  "<div id='c-seats' class='seats' aria-live='polite'></div>"
3173
- "<input type='search' id='c-member-filter' class='member-filter' spellcheck='false' autocomplete='off'"
3174
- " placeholder='Filter by model, provider or served name' aria-controls='c-members' aria-describedby='c-count'>"
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>"
3175
3190
  "<div id='c-members' class='members' role='group' aria-label='Members'></div>"
3176
3191
  "<p class='rules' id='c-count'></p>"
3177
3192
  "<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
@@ -3745,15 +3760,21 @@ def quota_tiles() -> list[dict]:
3745
3760
 
3746
3761
 
3747
3762
  def quota_op(which: str, tag) -> dict:
3748
- """Refresh or Check now on one tile: {ok, errors, note, tile}; "order"
3749
- saves the tiles' order (a list of tags, empty for the default sort)."""
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)."""
3750
3766
  import quotaview
3751
3767
  if which == "order":
3752
3768
  return quotaview.save_order(tag)
3753
3769
  if tag not in {t["id"] for t in quota_tiles()}:
3754
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)]}
3755
3776
  try:
3756
- r = (quotaview.refresh if which == "refresh" else quotaview.check)(tag)
3777
+ r = quotaview.refresh(tag)
3757
3778
  except (ValueError, OSError) as exc: # OSError: URLError and timeouts
3758
3779
  return {"ok": False, "errors": [str(exc)]}
3759
3780
  if not r.get("ok"):
@@ -3764,8 +3785,8 @@ def quota_op(which: str, tag) -> dict:
3764
3785
 
3765
3786
  def quotas_page() -> str:
3766
3787
  """A tile per provider: what is left and when it resets, shaped by how the
3767
- provider limits (metrics/quotaview.py), who is resting, Refresh and Check
3768
- now; the resets of the next week; what the bench sent (its usage ledger,
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,
3769
3790
  metrics/ledger.py); every limit known. bench.js draws tiles and charts."""
3770
3791
  import learn
3771
3792
  import ledger
@@ -3808,9 +3829,10 @@ def quotas_page() -> str:
3808
3829
  " Retries count. Calls through the llm CLI are not in the ledger.</p>")
3809
3830
  return (
3810
3831
  "<div class='panel'><h2>Quota</h2><p class='sub'>What is left with each provider and when it"
3811
- " resets, and who is resting after a rate limit. CLI Proxy API's credentials and OpenRouter are"
3812
- " read live by Refresh, which spends nothing; every other tile is what the provider last said"
3813
- " in its replies to the bench, and Check now, where it is offered, asks with one request."
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."
3814
3836
  " Drag a tile, or move it with the arrow keys, to keep it where you want it.</p>"
3815
3837
  "<div class='list-actions'><button class='btn' id='q-all'>Refresh all live</button>"
3816
3838
  "<button class='btn' id='q-reorder'>Sort: out first, then soonest reset</button>"
@@ -3832,6 +3854,7 @@ def quotas_page() -> str:
3832
3854
  else "<p class='muted'>None known yet.</p>")
3833
3855
  + "</details>"
3834
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>"
3835
3858
  "<p class='sub' id='q-models-sub'></p><div class='scroll' id='q-models-list'></div>"
3836
3859
  "<div class='list-actions'><button class='btn' id='q-models-close'>Close</button></div></dialog>"
3837
3860
  f"<script type='application/json' id='quota-data'>{data}</script>")
@@ -4347,7 +4370,8 @@ class Handler(gateway.Handler):
4347
4370
  "/api/gateway-key/remove": lambda p: revoke_gateway_key(str(p.get("name") or "")),
4348
4371
  "/api/theme": lambda p: save_theme(str(p.get("theme") or "")),
4349
4372
  "/api/quota/refresh": lambda p: quota_op("refresh", str(p.get("tag") or "")),
4350
- "/api/quota/check": lambda p: quota_op("check", str(p.get("tag") or "")),
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 "")),
4351
4375
  "/api/quota/order": lambda p: quota_op("order", p.get("order")),
4352
4376
  "/api/restart": lambda p: restart(p.get("force") is True)}
4353
4377
  # SAME ORIGIN ONLY. Any page open in the browser can POST a plain form
package/ui/bench.css CHANGED
@@ -299,6 +299,12 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
299
299
  #c-form .seats { min-height: 2rem; }
300
300
  #c-form .seat .k.after { margin: 0 0 0 .4rem; }
301
301
  :is(dialog, .form-panel) input.member-filter { font-size: .9375rem; padding: .4rem .6rem; margin-bottom: .2rem; }
302
+ #c-form .member-find { display: flex; gap: .5rem; }
303
+ #c-form .member-find input { flex: 1; min-width: 0; }
304
+ #c-form .member-find select { flex: none; width: auto; max-width: 45%; margin-bottom: .2rem; }
305
+ #c-form .seat .seat-x { border: 0; background: none; padding: 0 0 0 .45rem; font: inherit; font-size: 1rem; line-height: 1;
306
+ color: var(--muted); cursor: pointer; }
307
+ #c-form .seat .seat-x:hover, #c-form .seat .seat-x:focus-visible { color: var(--err); }
302
308
  :is(dialog, .form-panel) .members input:disabled + span { color: var(--muted); }
303
309
  /* Ruled out by the collection's tags (bench.js applyTags): greyed; a ticked
304
310
  one stays tickable and is marked, so it can be taken off. */
@@ -400,21 +406,35 @@ pre.log.answer { max-height: 40vh; }
400
406
  /* Quota: a tile per provider, as many columns as fit; a group of windows (a
401
407
  5-hour and a weekly limit of one model group) boxed together; a meter of
402
408
  ten segments filled like a bar. */
403
- .q-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: .75rem; align-items: start; margin-top: .75rem; }
404
- .q-tile { display: flex; flex-direction: column; gap: .45rem; padding: .75rem .85rem; background: var(--panel);
409
+ /* Tiles in a row share a height, so every footer sits on one line; nothing in
410
+ a tile is wider than it: names shorten, values never wrap. */
411
+ .q-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: .75rem; align-items: stretch; margin-top: .75rem; }
412
+ .q-tile { display: flex; flex-direction: column; gap: .5rem; padding: .75rem .85rem; background: var(--panel); min-width: 0;
405
413
  border: 1px solid var(--line); border-radius: var(--radius); cursor: grab; }
406
414
  .q-tile:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
407
415
  .q-tile.out { border-color: var(--err); }
408
416
  .q-tile.dragging { opacity: .45; }
409
- .q-tile .q-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
410
- .q-tile .nm { font-size: 1.0625rem; }
411
- .q-tag, .q-small { font-size: .8125rem; color: var(--muted); }
412
- .q-group { border: 1px solid var(--line); border-radius: var(--radius); padding: .45rem .55rem .55rem; display: flex; flex-direction: column; gap: .3rem; }
417
+ .q-tile .q-head { display: flex; justify-content: space-between; align-items: center; gap: .5rem; min-width: 0; }
418
+ .q-tile .nm { font-size: 1.0625rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
419
+ .q-tile .q-head .chip { flex: none; white-space: nowrap; font-size: .75rem; }
420
+ .q-about { display: flex; flex-direction: column; gap: .1rem; margin-top: -.3rem; font-size: .8125rem; color: var(--muted); }
421
+ .q-about .q-from { font-style: italic; }
422
+ .q-small { font-size: .8125rem; color: var(--muted); }
423
+ .q-body { display: flex; flex-direction: column; gap: .5rem; min-width: 0; }
424
+ .q-group { border: 1px solid var(--line); border-radius: var(--radius); padding: .45rem .55rem .55rem; display: flex; flex-direction: column; gap: .45rem; }
413
425
  .q-group.bare { border: 0; padding: 0; }
414
426
  .q-group h4 { margin: 0; font-size: .8125rem; font-weight: 500; color: var(--muted); }
415
- .q-line { display: flex; justify-content: space-between; gap: .5rem; font-size: .875rem; }
427
+ .q-meter { display: flex; flex-direction: column; gap: .2rem; min-width: 0; }
428
+ .q-line { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; font-size: .875rem; min-width: 0; }
416
429
  .q-line b { font-weight: 600; }
417
- .q-line .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
430
+ .q-line > :first-child { min-width: 0; }
431
+ .q-line > a.model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
432
+ .q-line .q-val { flex: none; white-space: nowrap; }.segs.none span { background: var(--step-1); opacity: .6; }
433
+ /* Calls: green for what answered, red for what failed, out of every call. */
434
+ .segs.calls span { background: var(--err); }
435
+ .segs.calls i { background: var(--ok); }
436
+ .q-calls { white-space: nowrap; }
437
+ .q-calls .segs { display: inline-flex; width: 5.5rem; margin-right: .5rem; vertical-align: middle; }
418
438
  .segs { display: flex; gap: 2px; }
419
439
  .segs span { flex: 1; height: .5rem; border-radius: 2px; background: var(--step-1); overflow: hidden; }
420
440
  .segs i { display: block; height: 100%; background: var(--ok); }
@@ -424,12 +444,13 @@ pre.log.answer { max-height: 40vh; }
424
444
  .warn-t { color: var(--warn); }
425
445
  .q-msg { font-size: .8125rem; }
426
446
  .q-msg:empty { display: none; }
427
- .q-tile .q-foot { display: flex; flex-wrap: wrap; gap: .4rem; justify-content: flex-end; margin-top: auto;
447
+ .q-tile .q-foot { display: flex; gap: .4rem; justify-content: space-between; margin-top: auto;
428
448
  padding-top: .5rem; border-top: 1px solid var(--line); }
429
449
  .q-tile .q-foot .chip { margin-left: .2rem; font-size: .75rem; }
430
450
  .q-legend { display: flex; flex-wrap: wrap; gap: 1rem; font-size: .875rem; margin: 0 0 .4rem; }
431
451
  .q-legend i { display: inline-block; width: .8rem; height: .7rem; border-radius: 2px; margin-right: .35rem; vertical-align: -1px; }
432
- #q-models { max-width: min(48rem, 94vw); }
452
+ #q-models { width: min(48rem, 94vw); max-width: none; }
453
+ #q-models td:first-child { white-space: nowrap; }
433
454
 
434
455
  /* Benchmarks charts (Observable Plot) */
435
456
  .chart { margin: .5rem 0 .75rem; overflow-x: auto; }
package/ui/bench.js CHANGED
@@ -411,6 +411,14 @@
411
411
  var act = b.dataset.act, aliases = (b.dataset.aliases || b.dataset.alias || '').split(' ').filter(Boolean);
412
412
  var status = b.closest('.problem').querySelector('.why');
413
413
  if (act === 'open') { openModel(aliases[0]); return; }
414
+ if (act === 'dismiss') { // a notice: gone once seen
415
+ b.disabled = true;
416
+ post('/api/alerts/dismiss', {key: b.dataset.key}).then(function (res) {
417
+ if (!res.ok) { status.textContent = res.errors.join('; '); status.classList.add('err'); b.disabled = false; return; }
418
+ location.reload();
419
+ }).catch(function (err) { status.textContent = String(err); b.disabled = false; });
420
+ return;
421
+ }
414
422
  if (act === 'retry') { // no confirm: one call, and only an answer changes anything
415
423
  b.disabled = true; b.textContent = 'Asking…';
416
424
  post('/api/retry', {alias: aliases[0]}).then(function (res) {
@@ -1330,7 +1338,7 @@
1330
1338
  head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
1331
1339
  gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
1332
1340
  del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
1333
- seats: $('c-seats')};
1341
+ seats: $('c-seats'), prov: $('c-member-provider')};
1334
1342
  var C = null, open = null; // the collection whose details are showing
1335
1343
  function explorersFor(n) {
1336
1344
  return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
@@ -1370,12 +1378,20 @@
1370
1378
  return {name: row ? row.dataset.name : by[f][0], on: by[f].map(function (a) { return fact(a).provider || a; })};
1371
1379
  });
1372
1380
  }
1373
- // The panel as it will sit, as the Collections list draws it.
1381
+ // The panel as it will sit, as the Collections list draws it; a member's ×
1382
+ // unticks it, as unticking its chip does.
1374
1383
  function drawSeats(ex) {
1375
1384
  c.seats.textContent = '';
1376
1385
  picked().forEach(function (a) {
1377
1386
  var seat = el('span', {class: 'seat'}, a);
1378
1387
  if (fact(a).provider) seat.appendChild(el('span', {class: 'k after'}, fact(a).provider));
1388
+ var x = el('button', {type: 'button', class: 'seat-x', 'aria-label': 'Remove ' + a + ' from the collection',
1389
+ title: 'Remove from the collection'}, '×');
1390
+ x.addEventListener('click', function () {
1391
+ var box = c.members.querySelector('input[value="' + CSS.escape(a) + '"]');
1392
+ if (box) { box.checked = false; applyTags(); }
1393
+ });
1394
+ seat.appendChild(x);
1379
1395
  c.seats.appendChild(seat);
1380
1396
  });
1381
1397
  for (var i = 0; i < ex; i++) {
@@ -1425,18 +1441,20 @@
1425
1441
  filterMembers();
1426
1442
  }
1427
1443
  // The member search: the filter boxes' match (any text, case-insensitive),
1428
- // over the model's name and each chip's alias, served name and provider.
1429
- // It only hides chips, and never a ticked one, so what is picked is always
1430
- // in view and every count still includes it; a row goes when all its chips do.
1444
+ // over the model's name and each chip's alias, served name and provider,
1445
+ // and the provider select beside it. It only hides chips, and never a ticked
1446
+ // one, so what is picked is always in view and every count still includes
1447
+ // it; a row goes when all its chips do.
1431
1448
  function filterMembers() {
1432
- var q = c.find.value.trim().toLowerCase();
1449
+ var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
1433
1450
  c.members.querySelectorAll('.m-row').forEach(function (row) {
1434
1451
  var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
1435
1452
  row.querySelectorAll('label.check').forEach(function (lab) {
1436
1453
  var box = lab.querySelector('input');
1437
1454
  var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
1438
1455
  lab.textContent).toLowerCase();
1439
- lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1);
1456
+ lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1
1457
+ || !!prov && (fact(box.value).provider || '') !== prov);
1440
1458
  any = any || !lab.hidden;
1441
1459
  });
1442
1460
  row.hidden = !any;
@@ -1444,6 +1462,7 @@
1444
1462
  recount();
1445
1463
  }
1446
1464
  c.find.addEventListener('input', filterMembers);
1465
+ c.prov.addEventListener('change', filterMembers);
1447
1466
  // Escape in a box with text clears it; only an empty box lets Escape close the popup.
1448
1467
  c.find.addEventListener('keydown', function (e) {
1449
1468
  if (e.key === 'Escape' && c.find.value) { e.preventDefault(); e.stopPropagation(); c.find.value = ''; filterMembers(); }
@@ -1487,6 +1506,9 @@
1487
1506
  c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
1488
1507
  drawTagPicks();
1489
1508
  c.members.textContent = ''; c.find.value = '';
1509
+ c.prov.length = 1; // "Every provider", then each one a model is on
1510
+ Array.from(new Set(C.models.map(function (a) { return fact(a).provider; }).filter(Boolean))).sort()
1511
+ .forEach(function (p) { c.prov.appendChild(el('option', {value: p}, p)); });
1490
1512
  // One row per model, named by its shortest alias, with what the first
1491
1513
  // route says it holds and can do; each provider it is on is a chip that
1492
1514
  // ticks that alias. A chip whose window differs from the row's says its own.
@@ -1698,91 +1720,135 @@
1698
1720
  var s = (Date.now() - new Date(iso)) / 1000;
1699
1721
  return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' days ago';
1700
1722
  }
1701
- function qTone(left) { return left >= 70 ? 'ok' : left >= 25 ? 'warn' : 'err'; }
1702
- function qSegs(left) {
1703
- var box = el('div', {class: 'segs ' + qTone(left), role: 'img', 'aria-label': left + '% left'});
1723
+ function qTone(pct) { return pct == null ? 'none' : pct >= 70 ? 'ok' : pct >= 25 ? 'warn' : 'err'; }
1724
+ // `pct` null draws the ten segments empty: nothing to count yet. Calls
1725
+ // ("answered") fill green over red, the failed share showing as red.
1726
+ function qSegs(pct, what) {
1727
+ var tone = pct != null && what === 'answered' ? 'calls' : qTone(pct);
1728
+ var box = el('div', {class: 'segs ' + tone, role: 'img',
1729
+ 'aria-label': pct == null ? 'nothing yet' : pct + '% ' + (what || 'left')});
1704
1730
  for (var i = 0; i < 10; i++) {
1705
1731
  var s = el('span'), f = el('i');
1706
- f.style.width = Math.round(Math.min(Math.max(left / 10 - i, 0), 1) * 100) + '%';
1732
+ f.style.width = Math.round(Math.min(Math.max((pct || 0) / 10 - i, 0), 1) * 100) + '%';
1707
1733
  s.appendChild(f); box.appendChild(s);
1708
1734
  }
1709
1735
  return box;
1710
1736
  }
1711
- function qLine(label, right) {
1712
- var row = el('div', {class: 'q-line'});
1713
- row.appendChild(el('span', {}, label));
1714
- var r = el('span'); r.innerHTML = right; row.appendChild(r);
1715
- return row;
1716
- }
1717
1737
  function qNum(n) { return Number(n).toLocaleString(); }
1738
+ function qAlias(a) { // as serve.named draws a model: its popup on a click
1739
+ var link = el('a', {href: '#', class: 'model', 'data-alias': a, draggable: 'false', title: a});
1740
+ link.appendChild(el('span', {class: 'alias mono'}, a));
1741
+ return link;
1742
+ }
1743
+ // 189, 19,997, 494k, 12.5M: whole below 100,000, then short.
1744
+ function qShort(n) {
1745
+ n = Number(n);
1746
+ if (n < 1e5) return Math.round(n * 100) / 100 >= 1000 ? Math.round(n).toLocaleString() : String(Math.round(n * 100) / 100);
1747
+ if (n < 1e6) return Math.round(n / 1e3) + 'k';
1748
+ return String(n >= 1e8 ? Math.round(n / 1e6) : Math.round(n / 1e5) / 10).replace(/\.0$/, '') + 'M';
1749
+ }
1750
+ // Every figure on a tile is one meter: what is left of what, in the
1751
+ // provider's own unit, on the left (it may wrap); when it resets on the
1752
+ // right (it never does); the segments under it.
1753
+ function qMeter(label, value, pct, what, title) {
1754
+ var box = el('div', {class: 'q-meter'}), row = el('div', {class: 'q-line'});
1755
+ var l = typeof label === 'string' ? el('span', {class: 'q-label'}) : label;
1756
+ if (typeof label === 'string') l.innerHTML = label;
1757
+ row.appendChild(l);
1758
+ var v = el('span', {class: 'q-val'}); v.innerHTML = value; row.appendChild(v);
1759
+ if (title) box.title = title;
1760
+ box.appendChild(row); box.appendChild(qSegs(pct, what));
1761
+ return box;
1762
+ }
1763
+ function qReset(iso) {
1764
+ return iso ? '<span title="Resets ' + new Date(iso).toLocaleString() + '">' + qWhen(iso) + '</span>' : '';
1765
+ }
1718
1766
  function qWindow(w) {
1719
- var frag = document.createDocumentFragment();
1720
- var row = qLine(w.label, '<b>' + w.left + '%</b>' + (w.reset_at ? ' · ' + qWhen(w.reset_at) : ''));
1721
- if (w.of) row.title = qNum(w.remaining) + ' of ' + qNum(w.of) + ' left';
1722
- frag.appendChild(row);
1723
- frag.appendChild(qSegs(w.left));
1724
- return frag;
1767
+ var raw = w.of != null && w.remaining != null;
1768
+ // The percentage leads every meter, raw or not, so they line up.
1769
+ return qMeter('<b>' + w.left + '%</b> left · ' + (raw ? '<b>' + qShort(w.remaining) + '</b> of '
1770
+ + qShort(w.of) + ' ' + w.label : w.label),
1771
+ qReset(w.reset_at), w.left, 'left',
1772
+ (raw ? qNum(w.remaining) + ' of ' + qNum(w.of) + ' left, ' : '') + w.left + '%');
1773
+ }
1774
+ // How much of a last day answered: a model's (its link), or `label`'s.
1775
+ function qCalls(m, label) {
1776
+ var n = m.ok + m.failed, pct = n ? Math.round(100 * m.ok / n) : null;
1777
+ var value = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
1778
+ : n ? '<b>' + m.ok + '</b> of ' + n + ' answered' : '<span class="muted">no calls</span>';
1779
+ return qMeter(label || qAlias(m.alias), value, pct, 'answered',
1780
+ n ? m.ok + ' answered, ' + m.failed + ' failed in the last 24 hours' : 'not called in the last 24 hours');
1725
1781
  }
1726
1782
  function qResting(m) { return m.until && new Date(m.until) > Date.now(); }
1727
1783
  function qTile(t) {
1728
1784
  var node = el('article', {class: 'q-tile' + (t.out ? ' out' : ''), draggable: 'true', tabindex: '0',
1729
1785
  'data-id': t.id, 'aria-label': t.name + ': drag, or use the arrow keys, to move it'});
1730
1786
  var head = el('div', {class: 'q-head'});
1731
- head.appendChild(el('b', {class: 'nm'}, t.name));
1732
- var f = t.fresh, how = f.how === 'live' ? 'read ' : f.how === 'seen' ? 'seen ' : 'answered ';
1733
- head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via')}, f.at ? how + qAgo(f.at) : 'nothing yet'));
1787
+ head.appendChild(el('b', {class: 'nm', title: t.name}, t.name));
1788
+ var f = t.fresh, how = {live: 'read ', seen: 'seen ', answered: 'answered '}[f.how] || '';
1789
+ head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via'),
1790
+ title: {live: 'Last asked of the provider', seen: 'Last stated in a reply\'s headers',
1791
+ answered: 'Last answer from one of its models'}[f.how] || 'Nothing known yet'},
1792
+ f.at ? how + qAgo(f.at) : 'nothing yet'));
1734
1793
  node.appendChild(head);
1735
- node.appendChild(el('div', {class: 'q-tag mono'}, t.tag + (t.plan ? ' · ' + t.plan : '')));
1794
+ // Every word about the figures in one place, under the name: where they
1795
+ // come from, its plan, your per-minute caps, what a live read said. The
1796
+ // connection's own name and address are in Models.
1797
+ var about = el('div', {class: 'q-about'});
1798
+ var src = t.source || [];
1799
+ about.appendChild(el('div', {class: 'q-from'}, (src.length && src[0] !== 'nothing stated'
1800
+ ? 'From ' + src.join('; ') : 'No limit stated') + (t.plan ? ' · ' + t.plan + ' plan' : '')));
1801
+ if (t.conn_limit) about.appendChild(el('div', {}, 'Your cap: ' + t.conn_limit + '.'));
1802
+ (t.notes || []).forEach(function (n) { about.appendChild(el('div', {}, n)); });
1803
+ node.appendChild(about);
1804
+ var body = el('div', {class: 'q-body'});
1805
+ // Day and month caps, counted by the bench: yours, else what the provider
1806
+ // documents. The From line names the source; the title says the rest.
1807
+ (t.caps || []).forEach(function (g) {
1808
+ var box = el('section', {class: 'q-group bare', title: g.title || ''});
1809
+ g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1810
+ body.appendChild(box);
1811
+ });
1736
1812
  if (t.kind === 'groups') {
1737
1813
  t.groups.forEach(function (g) {
1738
1814
  var box = el('section', {class: g.label ? 'q-group' : 'q-group bare'});
1739
1815
  if (g.label) box.appendChild(el('h4', {}, g.label));
1740
1816
  g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1741
- node.appendChild(box);
1817
+ body.appendChild(box);
1742
1818
  });
1743
1819
  } else if (t.kind === 'pool') {
1744
1820
  t.pool.forEach(function (p) {
1745
- var box = el('section', {class: 'q-group bare'});
1746
- if (p.of) {
1747
- box.appendChild(qLine(p.label, '<b>' + qNum(Math.max(0, p.of - p.used)) + '</b> of ' + qNum(p.of) + ' left'
1748
- + (p.reset_at ? ' · ' + qWhen(p.reset_at) : '')));
1749
- box.appendChild(qSegs(p.left));
1750
- } else {
1751
- box.appendChild(qLine(p.label, '<b>' + qNum(p.used) + '</b> sent · limit not known yet'));
1752
- }
1753
- node.appendChild(box);
1754
- });
1755
- } else {
1756
- var shown = t.models.slice(0, 6);
1757
- shown.forEach(function (m) {
1758
- var right = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
1759
- : m.ok + m.failed ? m.ok + ' ok · ' + m.failed + ' failed' : 'no calls today';
1760
- var row = qLine('', right); row.firstChild.className = 'mono'; row.firstChild.textContent = m.alias;
1761
- node.appendChild(row);
1821
+ body.appendChild(p.of
1822
+ ? qMeter('<b>' + p.left + '%</b> left · <b>' + qShort(Math.max(0, p.of - p.used)) + '</b> of '
1823
+ + qShort(p.of) + ' ' + p.label,
1824
+ qReset(p.reset_at), p.left, 'left')
1825
+ : qMeter('<b>' + qShort(p.used) + '</b> ' + p.label + ' · limit not known', qReset(p.reset_at), null));
1762
1826
  });
1763
- if (t.models.length > shown.length) node.appendChild(el('div', {class: 'muted q-small'}, '+ ' + (t.models.length - shown.length) + ' more in Models · last 24 h'));
1764
- else node.appendChild(el('div', {class: 'muted q-small'}, 'last 24 h'));
1827
+ } else if (!(t.caps || []).length) {
1828
+ // Nothing stated to count against: how the provider's calls went, all
1829
+ // its models together (each one's are in Models).
1830
+ var all = t.models.reduce(function (s, m) { return {ok: s.ok + m.ok, failed: s.failed + m.failed}; }, {ok: 0, failed: 0});
1831
+ body.appendChild(qCalls({alias: '', ok: all.ok, failed: all.failed, until: null}, 'Calls, last 24 hours'));
1765
1832
  }
1766
1833
  if (t.kind !== 'models') {
1767
1834
  t.models.filter(qResting).forEach(function (m) {
1768
- node.appendChild(el('div', {class: 'q-rest'}, m.alias + ' resting · back ' + qWhen(m.until)));
1835
+ var r = el('div', {class: 'q-rest'});
1836
+ r.appendChild(qAlias(m.alias)); r.appendChild(document.createTextNode(' resting · back ' + qWhen(m.until)));
1837
+ body.appendChild(r);
1769
1838
  });
1770
1839
  }
1771
- if (t.conn_limit) node.appendChild(el('div', {class: 'muted q-small'}, t.conn_limit + ', set by you'));
1772
- (t.notes || []).forEach(function (n) { node.appendChild(el('div', {class: 'muted q-small'}, n)); });
1840
+ node.appendChild(body);
1773
1841
  var msg = el('div', {class: 'q-msg', 'aria-live': 'polite'});
1774
1842
  node.appendChild(msg);
1843
+ // One Refresh: the server picks a free read, one short call or the records.
1775
1844
  var foot = el('div', {class: 'q-foot'});
1776
1845
  var rb = el('button', {class: 'btn small', type: 'button', 'aria-label': 'Refresh ' + t.name,
1777
- title: t.refresh === 'live' ? 'Ask the provider what is left (free)' : 'Read the bench\'s own records again (free)'});
1846
+ title: {live: 'Asks the provider what is left (free)',
1847
+ check: 'Sends one 16-token request; the reply\'s headers say what is left'}[t.refresh]
1848
+ || 'Reads the bench\'s own records again (this provider states no limits to ask about)'});
1778
1849
  rb.innerHTML = '&#x21bb; Refresh';
1779
- rb.addEventListener('click', function () { qAct('refresh', t.id, node); });
1850
+ rb.addEventListener('click', function () { qAct(t.id, node); });
1780
1851
  foot.appendChild(rb);
1781
- if (t.check) {
1782
- var cb = el('button', {class: 'btn small', type: 'button', title: 'One short call to one of its models; its reply says what is left'}, 'Check now · 1 request');
1783
- cb.addEventListener('click', function () { qAct('check', t.id, node); });
1784
- foot.appendChild(cb);
1785
- }
1786
1852
  var mb = el('button', {class: 'btn small', type: 'button'}, 'Models ');
1787
1853
  mb.appendChild(el('span', {class: 'chip ' + (t.live === t.bench ? 'ok' : t.live ? 'warn' : 'err')}, t.live + '/' + t.bench));
1788
1854
  mb.title = t.live + ' of the ' + t.bench + ' bench models on it are free to call';
@@ -1791,10 +1857,10 @@
1791
1857
  node.appendChild(foot);
1792
1858
  return node;
1793
1859
  }
1794
- function qAct(which, id, node) {
1860
+ function qAct(id, node) {
1795
1861
  var msg = node.querySelector('.q-msg');
1796
- msg.className = 'q-msg muted'; msg.textContent = which === 'check' ? 'Asking…' : 'Refreshing…';
1797
- return post('/api/quota/' + which, {tag: id}).then(function (r) {
1862
+ msg.className = 'q-msg muted'; msg.textContent = 'Refreshing…';
1863
+ return post('/api/quota/refresh', {tag: id}).then(function (r) {
1798
1864
  if (!r.ok) { msg.className = 'q-msg errors'; msg.textContent = r.errors.join('; '); return; }
1799
1865
  var i = QD.tiles.findIndex(function (t) { return t.id === id; });
1800
1866
  if (r.tile && i >= 0) {
@@ -1809,17 +1875,37 @@
1809
1875
  function qModels(t) {
1810
1876
  $('q-models-h').textContent = t.name + ': ' + t.bench + ' on the bench';
1811
1877
  $('q-models-sub').textContent = t.live + ' free to call now · the last 24 hours of calls';
1878
+ // How it is reached, asked when the popup opens (it reads Open WebUI's connections).
1879
+ var reach = $('q-models-about');
1880
+ function reachLine(after) {
1881
+ reach.textContent = '';
1882
+ var line = el('div'); line.appendChild(el('span', {class: 'mono'}, t.tag));
1883
+ if (after) line.appendChild(document.createTextNode(' · ' + after));
1884
+ reach.appendChild(line);
1885
+ }
1886
+ reachLine('finding how it is reached…');
1887
+ post('/api/quota/about', {tag: t.id}).then(function (r) {
1888
+ if (!r.ok) { reachLine(r.errors.join('; ')); return; }
1889
+ reachLine(r.routes.length ? '' : 'no endpoint or Open WebUI connection by this name');
1890
+ r.routes.forEach(function (x) {
1891
+ var d = el('div', {}, 'Through ' + x.via + ': ');
1892
+ d.appendChild(el('code', {}, x.url)); reach.appendChild(d);
1893
+ });
1894
+ }).catch(function (e) { reachLine(String(e)); });
1812
1895
  var list = $('q-models-list'); list.textContent = '';
1813
1896
  var tb = el('table'), hd = el('tr');
1814
1897
  ['Model', 'Now', 'Last 24 h', 'Last answered', 'Known limit'].forEach(function (h) { hd.appendChild(el('th', {}, h)); });
1815
1898
  tb.appendChild(el('thead')).appendChild(hd);
1816
1899
  var body = tb.appendChild(el('tbody'));
1817
1900
  t.models.forEach(function (m) {
1818
- var tr = el('tr');
1819
- tr.appendChild(el('td', {class: 'mono'}, m.alias));
1901
+ var tr = el('tr'), name = el('td');
1902
+ name.appendChild(qAlias(m.alias)); tr.appendChild(name);
1820
1903
  var now = el('td'); now.appendChild(el('span', {class: 'chip ' + (qResting(m) ? 'warn' : 'ok')}, qResting(m) ? 'resting · ' + qWhen(m.until) : 'ready'));
1821
1904
  tr.appendChild(now);
1822
- tr.appendChild(el('td', {}, m.ok + ' ok · ' + m.failed + ' failed'));
1905
+ var n = m.ok + m.failed, calls = el('td', {class: 'q-calls', title: m.failed + ' failed'});
1906
+ calls.appendChild(qSegs(n ? Math.round(100 * m.ok / n) : null, 'answered'));
1907
+ calls.appendChild(el('span', {}, n ? m.ok + ' of ' + n + ' answered' : 'no calls'));
1908
+ tr.appendChild(calls);
1823
1909
  tr.appendChild(el('td', {}, m.last_ok ? qAgo(m.last_ok) : '—'));
1824
1910
  tr.appendChild(el('td', {}, m.limit || '—'));
1825
1911
  body.appendChild(tr);
@@ -1888,7 +1974,7 @@
1888
1974
  live.reduce(function (p, t) {
1889
1975
  return p.then(function () {
1890
1976
  var node = qBox.querySelector('.q-tile[data-id="' + CSS.escape(t.id) + '"]');
1891
- return node ? qAct('refresh', t.id, node) : null;
1977
+ return node ? qAct(t.id, node) : null;
1892
1978
  });
1893
1979
  }, Promise.resolve()).then(function () { $('q-done').textContent = 'Refreshed ' + live.length + '.'; });
1894
1980
  });
@@ -1931,6 +2017,7 @@
1931
2017
  QD.tiles.forEach(function (t) {
1932
2018
  var wins = [];
1933
2019
  t.groups.forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: (g.label ? g.label + ' · ' : '') + w.label, at: w.reset_at}); }); });
2020
+ (t.caps || []).forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: w.label + ' (' + g.label + ')', at: w.reset_at, daily: / day$/.test(w.label)}); }); });
1934
2021
  t.pool.forEach(function (p) { if (p.reset_at) wins.push({label: p.label, at: p.reset_at, daily: true}); });
1935
2022
  wins.forEach(function (w) {
1936
2023
  var at = +new Date(w.at), daily = w.daily || / day$/.test(w.label);