@wendellresearch-ai/benchwarmers 0.5.0 → 0.5.2

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.
@@ -486,6 +486,15 @@ def main() -> int:
486
486
  print(("ok " if ok else "FAIL ") + "provider chip names" + ("" if ok else f" -> {got}"))
487
487
  failures += 0 if ok else 1
488
488
 
489
+ # Got it clears a notice (NVIDIA changed its figures), never a state still wrong.
490
+ alerts.raise_alert("test:notice", "limits", "a notice", dismiss=True)
491
+ alerts.raise_alert("test:state", "limits", "a state")
492
+ ok = (serve.dismiss_alert("test:notice")["ok"] and not serve.dismiss_alert("test:state")["ok"]
493
+ and "test:notice" not in alerts.active() and "test:state" in alerts.active())
494
+ alerts.clear("test:state")
495
+ print(("ok " if ok else "FAIL ") + "Got it dismisses a notice, not a state")
496
+ failures += 0 if ok else 1
497
+
489
498
  # The owner's files are as they were. alerts.json can change under a
490
499
  # running suite, so only what this test could do counts: the victim's
491
500
  # alerts cleared from the real file.
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()
@@ -2065,11 +2078,13 @@ def problems() -> str:
2065
2078
  folded = (f"<details><summary>Retired automatically this week · {len(auto)}"
2066
2079
  " <span class='muted'>— each can be restored</span></summary>"
2067
2080
  f"{render_rows(auto)}</details>") if auto else ""
2068
- return (f"<section class='problems' id='actions' aria-labelledby='actions-head'>"
2081
+ # Folded by default, the count in its heading; bench.js keeps it open for
2082
+ # 12 hours once shown.
2083
+ return (f"<section class='problems collapsed' id='actions' aria-labelledby='actions-head'>"
2069
2084
  f"<div class='head'><h2 id='actions-head'>Action needed · {len(items)}</h2>"
2070
2085
  "<span class='sep' aria-hidden='true'>·</span>"
2071
- "<button type='button' class='linkish' id='actions-hide' aria-expanded='true'"
2072
- " title='Fold this away until something new needs you, or for 12 hours'>Hide</button></div>"
2086
+ "<button type='button' class='linkish' id='actions-hide' aria-expanded='false'"
2087
+ " title='Open it here for 12 hours'>show</button></div>"
2073
2088
  + ("<p>States that will not fix themselves.</p>" if items
2074
2089
  else "<p>Nothing open.</p>")
2075
2090
  + f"{render_rows(items)}{folded}</section>")
@@ -3170,8 +3185,10 @@ def collections_tab() -> str:
3170
3185
  # The panel as it will sit, drawn as the Collections list draws it
3171
3186
  # (bench.js recount): a seat per ticked member, explorer seats dashed.
3172
3187
  "<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'>"
3188
+ "<div class='member-find'><input type='search' id='c-member-filter' class='member-filter' spellcheck='false'"
3189
+ " autocomplete='off' placeholder='Filter by model, provider or served name' aria-controls='c-members'"
3190
+ " aria-describedby='c-count'><select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
3191
+ "<option value=''>Every provider</option></select></div>"
3175
3192
  "<div id='c-members' class='members' role='group' aria-label='Members'></div>"
3176
3193
  "<p class='rules' id='c-count'></p>"
3177
3194
  "<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
@@ -3745,15 +3762,21 @@ def quota_tiles() -> list[dict]:
3745
3762
 
3746
3763
 
3747
3764
  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)."""
3765
+ """Refresh on one tile, which picks a free read, one short call or the
3766
+ records (quotaview.refresh): {ok, errors, note, tile}; "order" saves the
3767
+ tiles' order (a list of tags, empty for the default sort)."""
3750
3768
  import quotaview
3751
3769
  if which == "order":
3752
3770
  return quotaview.save_order(tag)
3753
3771
  if tag not in {t["id"] for t in quota_tiles()}:
3754
3772
  return {"ok": False, "errors": [f"no tile called {tag!r}"]}
3773
+ if which == "about": # the Models popup: how it is reached
3774
+ try:
3775
+ return {"ok": True, "errors": [], **quotaview.about(tag)}
3776
+ except (ValueError, OSError) as exc:
3777
+ return {"ok": False, "errors": [str(exc)]}
3755
3778
  try:
3756
- r = (quotaview.refresh if which == "refresh" else quotaview.check)(tag)
3779
+ r = quotaview.refresh(tag)
3757
3780
  except (ValueError, OSError) as exc: # OSError: URLError and timeouts
3758
3781
  return {"ok": False, "errors": [str(exc)]}
3759
3782
  if not r.get("ok"):
@@ -3764,8 +3787,8 @@ def quota_op(which: str, tag) -> dict:
3764
3787
 
3765
3788
  def quotas_page() -> str:
3766
3789
  """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,
3790
+ provider limits (metrics/quotaview.py), who is resting, one Refresh; the
3791
+ resets of the next week; what the bench sent (its usage ledger,
3769
3792
  metrics/ledger.py); every limit known. bench.js draws tiles and charts."""
3770
3793
  import learn
3771
3794
  import ledger
@@ -3808,9 +3831,10 @@ def quotas_page() -> str:
3808
3831
  " Retries count. Calls through the llm CLI are not in the ledger.</p>")
3809
3832
  return (
3810
3833
  "<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."
3834
+ " resets, and who is resting after a rate limit. Refresh asks the provider for free where it can"
3835
+ " (CLI Proxy API's credentials, OpenRouter); where a provider states its limits only in its replies,"
3836
+ " it sends one 16-token request to read them; elsewhere it reads the bench's own records again."
3837
+ " Hover a Refresh to see which it will do."
3814
3838
  " Drag a tile, or move it with the arrow keys, to keep it where you want it.</p>"
3815
3839
  "<div class='list-actions'><button class='btn' id='q-all'>Refresh all live</button>"
3816
3840
  "<button class='btn' id='q-reorder'>Sort: out first, then soonest reset</button>"
@@ -3832,6 +3856,7 @@ def quotas_page() -> str:
3832
3856
  else "<p class='muted'>None known yet.</p>")
3833
3857
  + "</details>"
3834
3858
  "<dialog id='q-models' aria-labelledby='q-models-h'><h3 id='q-models-h'></h3>"
3859
+ "<div class='sub q-reach' id='q-models-about'></div>"
3835
3860
  "<p class='sub' id='q-models-sub'></p><div class='scroll' id='q-models-list'></div>"
3836
3861
  "<div class='list-actions'><button class='btn' id='q-models-close'>Close</button></div></dialog>"
3837
3862
  f"<script type='application/json' id='quota-data'>{data}</script>")
@@ -4347,7 +4372,8 @@ class Handler(gateway.Handler):
4347
4372
  "/api/gateway-key/remove": lambda p: revoke_gateway_key(str(p.get("name") or "")),
4348
4373
  "/api/theme": lambda p: save_theme(str(p.get("theme") or "")),
4349
4374
  "/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 "")),
4375
+ "/api/quota/about": lambda p: quota_op("about", str(p.get("tag") or "")),
4376
+ "/api/alerts/dismiss": lambda p: dismiss_alert(str(p.get("key") or "")),
4351
4377
  "/api/quota/order": lambda p: quota_op("order", p.get("order")),
4352
4378
  "/api/restart": lambda p: restart(p.get("force") is True)}
4353
4379
  # SAME ORIGIN ONLY. Any page open in the browser can POST a plain form
package/suite/run.py CHANGED
@@ -554,6 +554,7 @@ STRUCTURAL = (
554
554
  "unknown model",
555
555
  "invalid model",
556
556
  "model_not_found",
557
+ "no model found", # Routeway: "No model found: <id>"
557
558
  )
558
559
  # ENTITLEMENT is its own class, and the difference decides what to DO.
559
560
  # `structural` means the model is not there and the registration should go.
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
@@ -123,23 +123,20 @@
123
123
  c.addEventListener(c.tagName === 'SELECT' ? 'change' : 'input', function () { applyFilter(scope); });
124
124
  });
125
125
 
126
- // ---- Action needed: Hide folds it to its heading, per browser, until an
127
- // action it has not seen appears or 12 hours pass, whichever is first ----
126
+ // ---- Action needed: folded to its heading by default; show keeps it open
127
+ // in this browser for 12 hours, Hide folds it again ----
128
128
  var acts = $('actions');
129
129
  if (acts) {
130
- var keys = Array.prototype.map.call(acts.querySelectorAll('.problem[data-key]'), function (p) { return p.dataset.key; });
131
- var hid = null;
132
- try { hid = JSON.parse(store('bench.actions.hidden') || 'null'); } catch (err) { hid = null; }
130
+ var opened = Number(store('bench.actions.open')) || 0;
133
131
  var fold = function (on) {
134
132
  acts.classList.toggle('collapsed', on);
135
133
  $('actions-hide').textContent = on ? 'show' : 'Hide';
136
134
  $('actions-hide').setAttribute('aria-expanded', String(!on));
137
135
  };
138
- fold(!!(hid && hid.keys && Date.now() - hid.at < 12 * 3600 * 1000
139
- && keys.every(function (k) { return hid.keys.indexOf(k) !== -1; })));
136
+ fold(!(Date.now() - opened < 12 * 3600 * 1000));
140
137
  $('actions-hide').addEventListener('click', function () {
141
138
  var on = !acts.classList.contains('collapsed');
142
- store('bench.actions.hidden', on ? JSON.stringify({at: Date.now(), keys: keys}) : '');
139
+ store('bench.actions.open', on ? '' : String(Date.now()));
143
140
  fold(on);
144
141
  });
145
142
  }
@@ -411,6 +408,14 @@
411
408
  var act = b.dataset.act, aliases = (b.dataset.aliases || b.dataset.alias || '').split(' ').filter(Boolean);
412
409
  var status = b.closest('.problem').querySelector('.why');
413
410
  if (act === 'open') { openModel(aliases[0]); return; }
411
+ if (act === 'dismiss') { // a notice: gone once seen
412
+ b.disabled = true;
413
+ post('/api/alerts/dismiss', {key: b.dataset.key}).then(function (res) {
414
+ if (!res.ok) { status.textContent = res.errors.join('; '); status.classList.add('err'); b.disabled = false; return; }
415
+ location.reload();
416
+ }).catch(function (err) { status.textContent = String(err); b.disabled = false; });
417
+ return;
418
+ }
414
419
  if (act === 'retry') { // no confirm: one call, and only an answer changes anything
415
420
  b.disabled = true; b.textContent = 'Asking…';
416
421
  post('/api/retry', {alias: aliases[0]}).then(function (res) {
@@ -1330,7 +1335,7 @@
1330
1335
  head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
1331
1336
  gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
1332
1337
  del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
1333
- seats: $('c-seats')};
1338
+ seats: $('c-seats'), prov: $('c-member-provider')};
1334
1339
  var C = null, open = null; // the collection whose details are showing
1335
1340
  function explorersFor(n) {
1336
1341
  return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
@@ -1370,12 +1375,20 @@
1370
1375
  return {name: row ? row.dataset.name : by[f][0], on: by[f].map(function (a) { return fact(a).provider || a; })};
1371
1376
  });
1372
1377
  }
1373
- // The panel as it will sit, as the Collections list draws it.
1378
+ // The panel as it will sit, as the Collections list draws it; a member's ×
1379
+ // unticks it, as unticking its chip does.
1374
1380
  function drawSeats(ex) {
1375
1381
  c.seats.textContent = '';
1376
1382
  picked().forEach(function (a) {
1377
1383
  var seat = el('span', {class: 'seat'}, a);
1378
1384
  if (fact(a).provider) seat.appendChild(el('span', {class: 'k after'}, fact(a).provider));
1385
+ var x = el('button', {type: 'button', class: 'seat-x', 'aria-label': 'Remove ' + a + ' from the collection',
1386
+ title: 'Remove from the collection'}, '×');
1387
+ x.addEventListener('click', function () {
1388
+ var box = c.members.querySelector('input[value="' + CSS.escape(a) + '"]');
1389
+ if (box) { box.checked = false; applyTags(); }
1390
+ });
1391
+ seat.appendChild(x);
1379
1392
  c.seats.appendChild(seat);
1380
1393
  });
1381
1394
  for (var i = 0; i < ex; i++) {
@@ -1425,18 +1438,20 @@
1425
1438
  filterMembers();
1426
1439
  }
1427
1440
  // 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.
1441
+ // over the model's name and each chip's alias, served name and provider,
1442
+ // and the provider select beside it. It only hides chips, and never a ticked
1443
+ // one, so what is picked is always in view and every count still includes
1444
+ // it; a row goes when all its chips do.
1431
1445
  function filterMembers() {
1432
- var q = c.find.value.trim().toLowerCase();
1446
+ var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
1433
1447
  c.members.querySelectorAll('.m-row').forEach(function (row) {
1434
1448
  var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
1435
1449
  row.querySelectorAll('label.check').forEach(function (lab) {
1436
1450
  var box = lab.querySelector('input');
1437
1451
  var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
1438
1452
  lab.textContent).toLowerCase();
1439
- lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1);
1453
+ lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1
1454
+ || !!prov && (fact(box.value).provider || '') !== prov);
1440
1455
  any = any || !lab.hidden;
1441
1456
  });
1442
1457
  row.hidden = !any;
@@ -1444,6 +1459,7 @@
1444
1459
  recount();
1445
1460
  }
1446
1461
  c.find.addEventListener('input', filterMembers);
1462
+ c.prov.addEventListener('change', filterMembers);
1447
1463
  // Escape in a box with text clears it; only an empty box lets Escape close the popup.
1448
1464
  c.find.addEventListener('keydown', function (e) {
1449
1465
  if (e.key === 'Escape' && c.find.value) { e.preventDefault(); e.stopPropagation(); c.find.value = ''; filterMembers(); }
@@ -1487,6 +1503,9 @@
1487
1503
  c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
1488
1504
  drawTagPicks();
1489
1505
  c.members.textContent = ''; c.find.value = '';
1506
+ c.prov.length = 1; // "Every provider", then each one a model is on
1507
+ Array.from(new Set(C.models.map(function (a) { return fact(a).provider; }).filter(Boolean))).sort()
1508
+ .forEach(function (p) { c.prov.appendChild(el('option', {value: p}, p)); });
1490
1509
  // One row per model, named by its shortest alias, with what the first
1491
1510
  // route says it holds and can do; each provider it is on is a chip that
1492
1511
  // ticks that alias. A chip whose window differs from the row's says its own.
@@ -1698,91 +1717,135 @@
1698
1717
  var s = (Date.now() - new Date(iso)) / 1000;
1699
1718
  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
1719
  }
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'});
1720
+ function qTone(pct) { return pct == null ? 'none' : pct >= 70 ? 'ok' : pct >= 25 ? 'warn' : 'err'; }
1721
+ // `pct` null draws the ten segments empty: nothing to count yet. Calls
1722
+ // ("answered") fill green over red, the failed share showing as red.
1723
+ function qSegs(pct, what) {
1724
+ var tone = pct != null && what === 'answered' ? 'calls' : qTone(pct);
1725
+ var box = el('div', {class: 'segs ' + tone, role: 'img',
1726
+ 'aria-label': pct == null ? 'nothing yet' : pct + '% ' + (what || 'left')});
1704
1727
  for (var i = 0; i < 10; i++) {
1705
1728
  var s = el('span'), f = el('i');
1706
- f.style.width = Math.round(Math.min(Math.max(left / 10 - i, 0), 1) * 100) + '%';
1729
+ f.style.width = Math.round(Math.min(Math.max((pct || 0) / 10 - i, 0), 1) * 100) + '%';
1707
1730
  s.appendChild(f); box.appendChild(s);
1708
1731
  }
1709
1732
  return box;
1710
1733
  }
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
1734
  function qNum(n) { return Number(n).toLocaleString(); }
1735
+ function qAlias(a) { // as serve.named draws a model: its popup on a click
1736
+ var link = el('a', {href: '#', class: 'model', 'data-alias': a, draggable: 'false', title: a});
1737
+ link.appendChild(el('span', {class: 'alias mono'}, a));
1738
+ return link;
1739
+ }
1740
+ // 189, 19,997, 494k, 12.5M: whole below 100,000, then short.
1741
+ function qShort(n) {
1742
+ n = Number(n);
1743
+ if (n < 1e5) return Math.round(n * 100) / 100 >= 1000 ? Math.round(n).toLocaleString() : String(Math.round(n * 100) / 100);
1744
+ if (n < 1e6) return Math.round(n / 1e3) + 'k';
1745
+ return String(n >= 1e8 ? Math.round(n / 1e6) : Math.round(n / 1e5) / 10).replace(/\.0$/, '') + 'M';
1746
+ }
1747
+ // Every figure on a tile is one meter: what is left of what, in the
1748
+ // provider's own unit, on the left (it may wrap); when it resets on the
1749
+ // right (it never does); the segments under it.
1750
+ function qMeter(label, value, pct, what, title) {
1751
+ var box = el('div', {class: 'q-meter'}), row = el('div', {class: 'q-line'});
1752
+ var l = typeof label === 'string' ? el('span', {class: 'q-label'}) : label;
1753
+ if (typeof label === 'string') l.innerHTML = label;
1754
+ row.appendChild(l);
1755
+ var v = el('span', {class: 'q-val'}); v.innerHTML = value; row.appendChild(v);
1756
+ if (title) box.title = title;
1757
+ box.appendChild(row); box.appendChild(qSegs(pct, what));
1758
+ return box;
1759
+ }
1760
+ function qReset(iso) {
1761
+ return iso ? '<span title="Resets ' + new Date(iso).toLocaleString() + '">' + qWhen(iso) + '</span>' : '';
1762
+ }
1718
1763
  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;
1764
+ var raw = w.of != null && w.remaining != null;
1765
+ // The percentage leads every meter, raw or not, so they line up.
1766
+ return qMeter('<b>' + w.left + '%</b> left · ' + (raw ? '<b>' + qShort(w.remaining) + '</b> of '
1767
+ + qShort(w.of) + ' ' + w.label : w.label),
1768
+ qReset(w.reset_at), w.left, 'left',
1769
+ (raw ? qNum(w.remaining) + ' of ' + qNum(w.of) + ' left, ' : '') + w.left + '%');
1770
+ }
1771
+ // How much of a last day answered: a model's (its link), or `label`'s.
1772
+ function qCalls(m, label) {
1773
+ var n = m.ok + m.failed, pct = n ? Math.round(100 * m.ok / n) : null;
1774
+ var value = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
1775
+ : n ? '<b>' + m.ok + '</b> of ' + n + ' answered' : '<span class="muted">no calls</span>';
1776
+ return qMeter(label || qAlias(m.alias), value, pct, 'answered',
1777
+ n ? m.ok + ' answered, ' + m.failed + ' failed in the last 24 hours' : 'not called in the last 24 hours');
1725
1778
  }
1726
1779
  function qResting(m) { return m.until && new Date(m.until) > Date.now(); }
1727
1780
  function qTile(t) {
1728
1781
  var node = el('article', {class: 'q-tile' + (t.out ? ' out' : ''), draggable: 'true', tabindex: '0',
1729
1782
  'data-id': t.id, 'aria-label': t.name + ': drag, or use the arrow keys, to move it'});
1730
1783
  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'));
1784
+ head.appendChild(el('b', {class: 'nm', title: t.name}, t.name));
1785
+ var f = t.fresh, how = {live: 'read ', seen: 'seen ', answered: 'answered '}[f.how] || '';
1786
+ head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via'),
1787
+ title: {live: 'Last asked of the provider', seen: 'Last stated in a reply\'s headers',
1788
+ answered: 'Last answer from one of its models'}[f.how] || 'Nothing known yet'},
1789
+ f.at ? how + qAgo(f.at) : 'nothing yet'));
1734
1790
  node.appendChild(head);
1735
- node.appendChild(el('div', {class: 'q-tag mono'}, t.tag + (t.plan ? ' · ' + t.plan : '')));
1791
+ // Every word about the figures in one place, under the name: where they
1792
+ // come from, its plan, your per-minute caps, what a live read said. The
1793
+ // connection's own name and address are in Models.
1794
+ var about = el('div', {class: 'q-about'});
1795
+ var src = t.source || [];
1796
+ about.appendChild(el('div', {class: 'q-from'}, (src.length && src[0] !== 'nothing stated'
1797
+ ? 'From ' + src.join('; ') : 'No limit stated') + (t.plan ? ' · ' + t.plan + ' plan' : '')));
1798
+ if (t.conn_limit) about.appendChild(el('div', {}, 'Your cap: ' + t.conn_limit + '.'));
1799
+ (t.notes || []).forEach(function (n) { about.appendChild(el('div', {}, n)); });
1800
+ node.appendChild(about);
1801
+ var body = el('div', {class: 'q-body'});
1802
+ // Day and month caps, counted by the bench: yours, else what the provider
1803
+ // documents. The From line names the source; the title says the rest.
1804
+ (t.caps || []).forEach(function (g) {
1805
+ var box = el('section', {class: 'q-group bare', title: g.title || ''});
1806
+ g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1807
+ body.appendChild(box);
1808
+ });
1736
1809
  if (t.kind === 'groups') {
1737
1810
  t.groups.forEach(function (g) {
1738
1811
  var box = el('section', {class: g.label ? 'q-group' : 'q-group bare'});
1739
1812
  if (g.label) box.appendChild(el('h4', {}, g.label));
1740
1813
  g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1741
- node.appendChild(box);
1814
+ body.appendChild(box);
1742
1815
  });
1743
1816
  } else if (t.kind === 'pool') {
1744
1817
  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);
1818
+ body.appendChild(p.of
1819
+ ? qMeter('<b>' + p.left + '%</b> left · <b>' + qShort(Math.max(0, p.of - p.used)) + '</b> of '
1820
+ + qShort(p.of) + ' ' + p.label,
1821
+ qReset(p.reset_at), p.left, 'left')
1822
+ : qMeter('<b>' + qShort(p.used) + '</b> ' + p.label + ' · limit not known', qReset(p.reset_at), null));
1762
1823
  });
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'));
1824
+ } else if (!(t.caps || []).length) {
1825
+ // Nothing stated to count against: how the provider's calls went, all
1826
+ // its models together (each one's are in Models).
1827
+ var all = t.models.reduce(function (s, m) { return {ok: s.ok + m.ok, failed: s.failed + m.failed}; }, {ok: 0, failed: 0});
1828
+ body.appendChild(qCalls({alias: '', ok: all.ok, failed: all.failed, until: null}, 'Calls, last 24 hours'));
1765
1829
  }
1766
1830
  if (t.kind !== 'models') {
1767
1831
  t.models.filter(qResting).forEach(function (m) {
1768
- node.appendChild(el('div', {class: 'q-rest'}, m.alias + ' resting · back ' + qWhen(m.until)));
1832
+ var r = el('div', {class: 'q-rest'});
1833
+ r.appendChild(qAlias(m.alias)); r.appendChild(document.createTextNode(' resting · back ' + qWhen(m.until)));
1834
+ body.appendChild(r);
1769
1835
  });
1770
1836
  }
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)); });
1837
+ node.appendChild(body);
1773
1838
  var msg = el('div', {class: 'q-msg', 'aria-live': 'polite'});
1774
1839
  node.appendChild(msg);
1840
+ // One Refresh: the server picks a free read, one short call or the records.
1775
1841
  var foot = el('div', {class: 'q-foot'});
1776
1842
  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)'});
1843
+ title: {live: 'Asks the provider what is left (free)',
1844
+ check: 'Sends one 16-token request; the reply\'s headers say what is left'}[t.refresh]
1845
+ || 'Reads the bench\'s own records again (this provider states no limits to ask about)'});
1778
1846
  rb.innerHTML = '&#x21bb; Refresh';
1779
- rb.addEventListener('click', function () { qAct('refresh', t.id, node); });
1847
+ rb.addEventListener('click', function () { qAct(t.id, node); });
1780
1848
  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
1849
  var mb = el('button', {class: 'btn small', type: 'button'}, 'Models ');
1787
1850
  mb.appendChild(el('span', {class: 'chip ' + (t.live === t.bench ? 'ok' : t.live ? 'warn' : 'err')}, t.live + '/' + t.bench));
1788
1851
  mb.title = t.live + ' of the ' + t.bench + ' bench models on it are free to call';
@@ -1791,10 +1854,10 @@
1791
1854
  node.appendChild(foot);
1792
1855
  return node;
1793
1856
  }
1794
- function qAct(which, id, node) {
1857
+ function qAct(id, node) {
1795
1858
  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) {
1859
+ msg.className = 'q-msg muted'; msg.textContent = 'Refreshing…';
1860
+ return post('/api/quota/refresh', {tag: id}).then(function (r) {
1798
1861
  if (!r.ok) { msg.className = 'q-msg errors'; msg.textContent = r.errors.join('; '); return; }
1799
1862
  var i = QD.tiles.findIndex(function (t) { return t.id === id; });
1800
1863
  if (r.tile && i >= 0) {
@@ -1809,17 +1872,37 @@
1809
1872
  function qModels(t) {
1810
1873
  $('q-models-h').textContent = t.name + ': ' + t.bench + ' on the bench';
1811
1874
  $('q-models-sub').textContent = t.live + ' free to call now · the last 24 hours of calls';
1875
+ // How it is reached, asked when the popup opens (it reads Open WebUI's connections).
1876
+ var reach = $('q-models-about');
1877
+ function reachLine(after) {
1878
+ reach.textContent = '';
1879
+ var line = el('div'); line.appendChild(el('span', {class: 'mono'}, t.tag));
1880
+ if (after) line.appendChild(document.createTextNode(' · ' + after));
1881
+ reach.appendChild(line);
1882
+ }
1883
+ reachLine('finding how it is reached…');
1884
+ post('/api/quota/about', {tag: t.id}).then(function (r) {
1885
+ if (!r.ok) { reachLine(r.errors.join('; ')); return; }
1886
+ reachLine(r.routes.length ? '' : 'no endpoint or Open WebUI connection by this name');
1887
+ r.routes.forEach(function (x) {
1888
+ var d = el('div', {}, 'Through ' + x.via + ': ');
1889
+ d.appendChild(el('code', {}, x.url)); reach.appendChild(d);
1890
+ });
1891
+ }).catch(function (e) { reachLine(String(e)); });
1812
1892
  var list = $('q-models-list'); list.textContent = '';
1813
1893
  var tb = el('table'), hd = el('tr');
1814
1894
  ['Model', 'Now', 'Last 24 h', 'Last answered', 'Known limit'].forEach(function (h) { hd.appendChild(el('th', {}, h)); });
1815
1895
  tb.appendChild(el('thead')).appendChild(hd);
1816
1896
  var body = tb.appendChild(el('tbody'));
1817
1897
  t.models.forEach(function (m) {
1818
- var tr = el('tr');
1819
- tr.appendChild(el('td', {class: 'mono'}, m.alias));
1898
+ var tr = el('tr'), name = el('td');
1899
+ name.appendChild(qAlias(m.alias)); tr.appendChild(name);
1820
1900
  var now = el('td'); now.appendChild(el('span', {class: 'chip ' + (qResting(m) ? 'warn' : 'ok')}, qResting(m) ? 'resting · ' + qWhen(m.until) : 'ready'));
1821
1901
  tr.appendChild(now);
1822
- tr.appendChild(el('td', {}, m.ok + ' ok · ' + m.failed + ' failed'));
1902
+ var n = m.ok + m.failed, calls = el('td', {class: 'q-calls', title: m.failed + ' failed'});
1903
+ calls.appendChild(qSegs(n ? Math.round(100 * m.ok / n) : null, 'answered'));
1904
+ calls.appendChild(el('span', {}, n ? m.ok + ' of ' + n + ' answered' : 'no calls'));
1905
+ tr.appendChild(calls);
1823
1906
  tr.appendChild(el('td', {}, m.last_ok ? qAgo(m.last_ok) : '—'));
1824
1907
  tr.appendChild(el('td', {}, m.limit || '—'));
1825
1908
  body.appendChild(tr);
@@ -1888,7 +1971,7 @@
1888
1971
  live.reduce(function (p, t) {
1889
1972
  return p.then(function () {
1890
1973
  var node = qBox.querySelector('.q-tile[data-id="' + CSS.escape(t.id) + '"]');
1891
- return node ? qAct('refresh', t.id, node) : null;
1974
+ return node ? qAct(t.id, node) : null;
1892
1975
  });
1893
1976
  }, Promise.resolve()).then(function () { $('q-done').textContent = 'Refreshed ' + live.length + '.'; });
1894
1977
  });
@@ -1931,6 +2014,7 @@
1931
2014
  QD.tiles.forEach(function (t) {
1932
2015
  var wins = [];
1933
2016
  t.groups.forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: (g.label ? g.label + ' · ' : '') + w.label, at: w.reset_at}); }); });
2017
+ (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
2018
  t.pool.forEach(function (p) { if (p.reset_at) wins.push({label: p.label, at: p.reset_at, daily: true}); });
1935
2019
  wins.forEach(function (w) {
1936
2020
  var at = +new Date(w.at), daily = w.daily || / day$/.test(w.label);