@wendellresearch-ai/benchwarmers 0.4.0 → 0.5.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/ui/bench.js CHANGED
@@ -20,9 +20,11 @@
20
20
  // The page's own confirm, a Promise of true/false: window.confirm is
21
21
  // silently false where a browser blocks dialogs. Opens over any open popup.
22
22
  var askD = $('ask-dialog'), askDone = null;
23
- function ask(text, yes, danger) {
23
+ function ask(text, yes, danger, onboard) {
24
24
  if (askDone) askDone(false);
25
25
  $('ask-text').textContent = text;
26
+ $('ask-onboard').hidden = !onboard; // Discover's Add: the Onboard dialog's mode and Vision box
27
+ $('ask-vision').checked = false;
26
28
  $('ask-yes').textContent = yes || 'OK';
27
29
  $('ask-yes').className = 'btn ' + (danger ? 'danger' : 'primary');
28
30
  askD.showModal(); $('ask-no').focus();
@@ -33,6 +35,34 @@
33
35
  $('ask-no').addEventListener('click', function () { answer(false); });
34
36
  askD.addEventListener('close', function () { answer(false); }); // Escape
35
37
 
38
+ // ---- restart (serve.restart): the page reloads once the new one answers ----
39
+ var rs = $('restart');
40
+ function restart(force) {
41
+ var out = $('restart-out');
42
+ rs.disabled = true; out.textContent = 'Restarting…';
43
+ post('/api/restart', {force: force}).then(function (res) {
44
+ if (res.ok) return back(Date.now());
45
+ rs.disabled = false; out.textContent = '';
46
+ if (!res.busy) { listErrors(out, res.errors); return; }
47
+ ask('Still in progress: ' + res.errors.join('; ') + '. Restart anyway and cut them off?', 'Restart anyway', true)
48
+ .then(function (yes) { if (yes) restart(true); });
49
+ }).catch(function (err) { rs.disabled = false; out.textContent = String(err); });
50
+ }
51
+ // The old one stops half a second after answering; /health answers again once the new one is up.
52
+ function back(since) {
53
+ setTimeout(function () {
54
+ fetch('/health', {cache: 'no-store'}).then(function (r) { if (!r.ok) throw r.status; location.reload(); })
55
+ .catch(function () {
56
+ if (Date.now() - since < 120000) back(since);
57
+ else $('restart-out').textContent = 'Not back after two minutes: look at whatever runs it.';
58
+ });
59
+ }, 1500);
60
+ }
61
+ if (rs) rs.addEventListener('click', function () {
62
+ ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
63
+ .then(function (yes) { if (yes) restart(false); });
64
+ });
65
+
36
66
  // ---- tabs, remembered per browser ----------------------------------
37
67
  var tabs = document.querySelectorAll('.tabs button[data-tab]'); // not the sidebar's own toggle
38
68
  var onShow = {}; // a page's loader, run each time it is shown
@@ -743,19 +773,21 @@
743
773
  function addVia(x, b) {
744
774
  var direct = x.via === 'direct';
745
775
  ask('Add ' + x.model + ' via ' + x.label + (x.free ? '' : ' (PAID)') + '?\n\n'
746
- + (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint, a 4-call probe, then the suite.'
776
+ + (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint and onboard it as chosen below.'
747
777
  // A LiteLLM or CLI Proxy API route says its own steps: only what that hub takes.
748
778
  : x.steps ? x.steps.map(function (s, i) { return (i + 1) + '. ' + s; }).join('\n')
749
779
  : '1. Open WebUI: add it to the ' + x.connection + ' connection\'s Model IDs (if that connection lists them)\n'
750
780
  + '2. Open WebUI: a public model record, tagged and with the logo of the connection\'s other models\n'
751
- + '3. The bench: register, a 4-call probe, then the suite'), 'Add').then(function (yes) { if (yes) addNow(x, b, direct); });
781
+ + '3. The bench: register it and onboard it as chosen below'), 'Add', false, true).then(function (yes) {
782
+ if (yes) addNow(x, b, direct, {mode: $('ask-mode').value, vision: $('ask-vision').checked});
783
+ });
752
784
  }
753
- function addNow(x, b, direct) {
785
+ function addNow(x, b, direct, how) {
754
786
  var title = 'Adding ' + x.model + ' via ' + x.label;
755
787
  $('d-errors').textContent = '';
756
788
  if (direct) {
757
789
  b.disabled = true; b.textContent = 'Adding…';
758
- post('/api/onboard-queue', {items: [{served: x.model, endpoint: x.endpoint}]}).then(function (res) {
790
+ post('/api/onboard-queue', {items: [Object.assign({served: x.model, endpoint: x.endpoint}, how)]}).then(function (res) {
759
791
  if (!res.ok) { listErrors($('d-errors'), res.errors); b.disabled = false; b.textContent = 'Add via this'; return; }
760
792
  dd.close(); preStep = null; o.errors.textContent = ''; od.showModal();
761
793
  afterQueued(res, {served: x.model, endpoint: x.endpoint}, title, '');
@@ -770,7 +802,7 @@
770
802
  runView(title, 'Adding it to ' + x.label + '…');
771
803
  oq.box.hidden = false; oq.chip.classList.remove('done', 'fail'); oq.chip.style.setProperty('--fill', '3%');
772
804
  oq.text.textContent = oq.text.title = title; oq.stop.hidden = oq.clear.hidden = true;
773
- post('/api/discover/add', {connection: x.connection, model: x.model, endpoint: x.endpoint}).then(function (res) {
805
+ post('/api/discover/add', Object.assign({connection: x.connection, model: x.model, endpoint: x.endpoint}, how)).then(function (res) {
774
806
  var hubDone = !!(res.served && res.steps && res.steps.length); // in the hub, even if queueing it was refused
775
807
  preStep.state = hubDone ? 'done' : 'fail';
776
808
  preStep.note = (res.steps || []).join('. ') + (hubDone ? '' : (res.steps && res.steps.length ? '. ' : '') + res.errors.join(' '));
@@ -802,8 +834,16 @@
802
834
  if (res.errors.length) listErrors($('d-errors'), res.errors); // a hub that could not be asked
803
835
  var r = res.model, p = $('d-profile'), list = $('d-routes');
804
836
  $('d-title').textContent = r.name;
805
- $('d-served').textContent = r.provider_name + ' · ' + r.model + ' · listed by ' + r.sources.join(' and ')
806
- + (r.on_bench_as ? ' · on the bench as ' + r.on_bench_as : '');
837
+ // Each source a link: FreeLLMAPI's page for the model where its sitemap has one, else its catalog.
838
+ var sv = $('d-served');
839
+ sv.textContent = r.provider_name + ' · ' + r.model + ' · listed by ';
840
+ r.sources.forEach(function (s, i) {
841
+ var fl = s === 'freellmapi';
842
+ if (i) sv.appendChild(document.createTextNode(' and '));
843
+ sv.appendChild(el('a', {href: fl ? r.page || 'https://freellmapi.co/models' : 'https://models.dev/',
844
+ target: '_blank', rel: 'noopener'}, fl ? 'FreeLLMAPI' : 'models.dev'));
845
+ });
846
+ if (r.on_bench_as) sv.appendChild(document.createTextNode(' · on the bench as ' + r.on_bench_as));
807
847
  p.textContent = '';
808
848
  [['Input', r.in_mods], ['Output', r.out_mods]].forEach(function (x) {
809
849
  var row = el('div', {class: 'prof'});
@@ -814,16 +854,26 @@
814
854
  var can = [['tools', r.tools], ['vision', r.vision], ['reasons', r.reasoning]]
815
855
  .filter(function (x) { return x[1]; }).map(function (x) { return x[0]; });
816
856
  var facts = [
857
+ r.rank != null ? 'smartest #' + r.rank + (r.speed ? ', fastest #' + r.speed : '') + ' on FreeLLMAPI' : '',
858
+ r.size || '',
817
859
  r.context ? 'context ' + Number(r.context).toLocaleString() + ' tokens' : 'context unknown',
818
860
  r.output ? 'output up to ' + Number(r.output).toLocaleString() : '',
819
861
  can.length ? 'can: ' + can.join(', ') : '',
820
862
  price(r),
821
863
  r.limits ? 'limits ' + limits(r.limits) : '',
864
+ r.budget ? r.budget + ' tokens a month' : '',
822
865
  r.released ? 'released ' + r.released : '',
823
866
  r.knowledge ? 'knowledge to ' + r.knowledge : '',
824
867
  r.open_weights != null ? (r.open_weights ? 'open weights' : 'closed weights') : '',
825
868
  'first seen ' + (r.first_seen === 'baseline' ? 'before the list began' : r.first_seen)];
826
869
  p.appendChild(el('div', {class: 'prof'}, facts.filter(Boolean).join(' · ')));
870
+ // FreeLLMAPI's operating notes for this provider, e.g. thinking tokens that eat the output cap.
871
+ (r.quirks || []).forEach(function (q) {
872
+ var row = el('div', {class: 'prof'});
873
+ row.appendChild(el('strong', {}, q.title + ': '));
874
+ row.appendChild(document.createTextNode(q.body || ''));
875
+ p.appendChild(row);
876
+ });
827
877
  if (r.also_on && r.also_on.length) p.appendChild(el('div', {class: 'prof muted'}, 'Also on the bench as ' + r.also_on.join(', ')));
828
878
  list.textContent = '';
829
879
  res.routes.forEach(function (x) {
@@ -1279,7 +1329,8 @@
1279
1329
  shape: $('c-shape'), desc: $('c-desc'), tags: $('c-tags'), errors: $('c-errors'), done: $('c-done'),
1280
1330
  head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
1281
1331
  gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
1282
- del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter')};
1332
+ del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
1333
+ seats: $('c-seats')};
1283
1334
  var C = null, open = null; // the collection whose details are showing
1284
1335
  function explorersFor(n) {
1285
1336
  return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
@@ -1299,10 +1350,37 @@
1299
1350
  (shown.length < rows.length ? ' · showing ' + shown.length + ' of ' + rows.length + ' models' : '') +
1300
1351
  (paid ? ' (' + paid + ' paid hidden by the free tag)' : '') +
1301
1352
  (greyed ? ' · ' + greyed + ' greyed out by the tags (hover one for why)' : '') +
1302
- (clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '');
1353
+ (clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '') +
1354
+ twice().map(function (t) {
1355
+ return ' · ' + t.name + ' is ticked on ' + t.on.join(' and ') + ': one model agrees with itself, it does not check itself';
1356
+ }).join('');
1303
1357
  c.members.querySelectorAll('input').forEach(function (x) {
1304
1358
  x.disabled = !x.checked && (n >= C.settings.max_calls || !!x.dataset.blocked);
1305
1359
  });
1360
+ drawSeats(ex);
1361
+ }
1362
+ function fact(alias) { return (C.facts || {})[alias] || {}; }
1363
+ // The same model ticked on two providers (route.family_of, as the gateway
1364
+ // keeps explorers off a member's model): allowed, and said.
1365
+ function twice() {
1366
+ var by = {};
1367
+ picked().forEach(function (a) { var f = fact(a).family || a; (by[f] = by[f] || []).push(a); });
1368
+ return Object.keys(by).filter(function (f) { return by[f].length > 1; }).map(function (f) {
1369
+ var row = c.members.querySelector('.m-row[data-family="' + CSS.escape(f) + '"]');
1370
+ return {name: row ? row.dataset.name : by[f][0], on: by[f].map(function (a) { return fact(a).provider || a; })};
1371
+ });
1372
+ }
1373
+ // The panel as it will sit, as the Collections list draws it.
1374
+ function drawSeats(ex) {
1375
+ c.seats.textContent = '';
1376
+ picked().forEach(function (a) {
1377
+ var seat = el('span', {class: 'seat'}, a);
1378
+ if (fact(a).provider) seat.appendChild(el('span', {class: 'k after'}, fact(a).provider));
1379
+ c.seats.appendChild(seat);
1380
+ });
1381
+ for (var i = 0; i < ex; i++) {
1382
+ c.seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (c.shape.value ? ' · ' + c.shape.value : '')));
1383
+ }
1306
1384
  }
1307
1385
  // Tags that say what every member must do: why a model does not fit them
1308
1386
  // (none: it fits). From collections_view's facts; an unknown context is
@@ -1340,20 +1418,28 @@
1340
1418
  lab.classList.toggle('clash', why.length > 0 && box.checked);
1341
1419
  lab.dataset.paid = paid ? '1' : '';
1342
1420
  if (why.length) box.dataset.blocked = '1'; else delete box.dataset.blocked;
1421
+ // A chip says only the provider: the alias and served name are its first line.
1343
1422
  var served = lab.dataset.served;
1344
- lab.title = (served && served !== box.value ? served : '') + (why.length ? (served && served !== box.value ? '\n' : '') + why.join('\n') : '');
1423
+ lab.title = [box.value + (served && served !== box.value ? ' (' + served + ')' : '')].concat(why).join('\n');
1345
1424
  });
1346
1425
  filterMembers();
1347
1426
  }
1348
1427
  // The member search: the filter boxes' match (any text, case-insensitive),
1349
- // over alias and served name. It only hides rows, and never a ticked one, so
1350
- // what is picked is always in view and every count still includes it.
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.
1351
1431
  function filterMembers() {
1352
1432
  var q = c.find.value.trim().toLowerCase();
1353
- c.members.querySelectorAll('label.check').forEach(function (lab) {
1354
- var box = lab.querySelector('input');
1355
- lab.hidden = !box.checked && (!!lab.dataset.paid ||
1356
- !!q && (box.value + ' ' + (lab.dataset.served || '')).toLowerCase().indexOf(q) === -1);
1433
+ c.members.querySelectorAll('.m-row').forEach(function (row) {
1434
+ var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
1435
+ row.querySelectorAll('label.check').forEach(function (lab) {
1436
+ var box = lab.querySelector('input');
1437
+ var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
1438
+ lab.textContent).toLowerCase();
1439
+ lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1);
1440
+ any = any || !lab.hidden;
1441
+ });
1442
+ row.hidden = !any;
1357
1443
  });
1358
1444
  recount();
1359
1445
  }
@@ -1384,6 +1470,7 @@
1384
1470
  c.tags.addEventListener('input', function () { drawTagPicks(); applyTags(); });
1385
1471
  function showMerger() { $('c-merger-row').hidden = c.reply.value !== 'merged'; }
1386
1472
  c.reply.addEventListener('change', showMerger);
1473
+ c.shape.addEventListener('change', recount); // the explorer seats say their shape
1387
1474
  // A collection opens in a popup, as every other edit on the dashboard does:
1388
1475
  // the one clicked, or a new one from New collection.
1389
1476
  function closeForm() {
@@ -1400,14 +1487,37 @@
1400
1487
  c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
1401
1488
  drawTagPicks();
1402
1489
  c.members.textContent = ''; c.find.value = '';
1403
- C.models.forEach(function (alias) {
1404
- var box = el('input', {type: 'checkbox', value: alias});
1405
- box.checked = !!row && row.members.indexOf(alias) !== -1;
1406
- box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
1407
- var served = (C.served || {})[alias] || '';
1408
- var lab = el('label', {class: 'check', 'data-served': served});
1409
- lab.appendChild(box); lab.appendChild(el('span', {class: 'mono'}, alias));
1410
- c.members.appendChild(lab);
1490
+ // One row per model, named by its shortest alias, with what the first
1491
+ // route says it holds and can do; each provider it is on is a chip that
1492
+ // ticks that alias. A chip whose window differs from the row's says its own.
1493
+ var groups = {};
1494
+ C.models.forEach(function (a) { var f = fact(a).family || a; (groups[f] = groups[f] || []).push(a); });
1495
+ Object.keys(groups).map(function (f) {
1496
+ var routes = groups[f].slice().sort(function (x, y) { return x.length - y.length || (x < y ? -1 : 1); });
1497
+ return {family: f, name: routes[0], routes: routes};
1498
+ }).sort(function (x, y) { return x.name.toLowerCase() < y.name.toLowerCase() ? -1 : 1; }).forEach(function (g) {
1499
+ var first = fact(g.name), caps = first.caps || {};
1500
+ var can = ['vision', 'tools'].filter(function (k) { return caps[k]; });
1501
+ var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
1502
+ var name = el('div', {class: 'm-name'});
1503
+ name.appendChild(el('span', {class: 'mono'}, g.name));
1504
+ name.appendChild(el('span', {class: 'm-facts'}, [first.context ? tokens(first.context) : '', can.join(', '),
1505
+ first.price === 'unknown' ? '' : first.price].filter(Boolean).join(' · ')));
1506
+ line.appendChild(name);
1507
+ var chips = el('div', {class: 'm-routes'});
1508
+ g.routes.forEach(function (alias) {
1509
+ var f = fact(alias);
1510
+ var box = el('input', {type: 'checkbox', value: alias, 'aria-label': alias + ' on ' + (f.provider || 'its endpoint')});
1511
+ box.checked = !!row && row.members.indexOf(alias) !== -1;
1512
+ box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
1513
+ var lab = el('label', {class: 'check', 'data-served': (C.served || {})[alias] || ''});
1514
+ lab.appendChild(box);
1515
+ lab.appendChild(el('span', null, (f.provider || alias) +
1516
+ (f.context && first.context && f.context !== first.context ? ' · ' + tokens(f.context) : '')));
1517
+ chips.appendChild(lab);
1518
+ });
1519
+ line.appendChild(chips);
1520
+ c.members.appendChild(line);
1411
1521
  });
1412
1522
  c.shape.textContent = '';
1413
1523
  c.shape.appendChild(el('option', {value: ''}, 'automatic'));
@@ -1418,10 +1528,18 @@
1418
1528
  c.merger.textContent = '';
1419
1529
  c.merger.appendChild(el('option', {value: ''}, 'automatic'));
1420
1530
  C.models.forEach(function (alias) {
1421
- var o = el('option', {value: alias}, alias); if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
1531
+ var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
1532
+ if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
1422
1533
  });
1423
1534
  showMerger(); applyTags();
1424
1535
  }
1536
+ // What a collection's members can all do, in the words the tag checks use.
1537
+ var CAN = {reasoning: 'reason'};
1538
+ Object.keys(CAP_TAGS).forEach(function (t) { CAN[CAP_TAGS[t][0]] = CAP_TAGS[t][1]; });
1539
+ function tokens(n) { // 512k, 1M (2^20), 1.9M
1540
+ return n >= 1000000 ? String(Math.round(n / 104857.6) / 10).replace(/\.0$/, '') + 'M'
1541
+ : Math.floor(n / 1000).toLocaleString() + 'k';
1542
+ }
1425
1543
  function loadCollections(keep) {
1426
1544
  fetch('/api/collections').then(function (r) { return r.json(); }).then(function (res) {
1427
1545
  C = res; c.list.textContent = '';
@@ -1430,24 +1548,38 @@
1430
1548
  var box = el('button', {class: 'endpoint c-row', type: 'button', 'data-name': row.name,
1431
1549
  'aria-label': 'Open bench/' + row.name});
1432
1550
  var left = el('div');
1433
- var title = el('div', {class: 'what'});
1434
- title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name + ' '));
1435
- row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip', style: 'margin-left:.3rem'}, t)); });
1551
+ var title = el('div', {class: 'top'});
1552
+ title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name));
1553
+ row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip'}, t)); });
1436
1554
  left.appendChild(title);
1437
- if (row.description) left.appendChild(el('div', {}, row.description));
1438
- left.appendChild(el('div', {class: 'base'}, row.members.join(', ') + ' + ' + row.explorers + ' explorer' +
1439
- (row.explorers === 1 ? '' : 's') + (row.shape ? ' (' + row.shape + ')' : '') +
1440
- (row.reply === 'merged' ? ' · merged by ' + (row.merger || 'verdicts') : '')));
1555
+ if (row.description) left.appendChild(el('p', {class: 'desc'}, row.description));
1556
+ // The panel as it sits: a seat per member, an empty one per explorer,
1557
+ // and the merger after an arrow.
1558
+ var seats = el('div', {class: 'seats'});
1559
+ row.members.forEach(function (m) { seats.appendChild(el('span', {class: 'seat'}, m)); });
1560
+ for (var i = 0; i < row.explorers; i++) {
1561
+ seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (row.shape ? ' · ' + row.shape : '')));
1562
+ }
1563
+ if (row.reply === 'merged') {
1564
+ var merge = el('span', {class: 'merge'}), seat = el('span', {class: 'seat merger'});
1565
+ merge.appendChild(el('span', {'aria-hidden': 'true'}, '→'));
1566
+ seat.appendChild(el('span', {class: 'k'}, 'merger'));
1567
+ seat.appendChild(document.createTextNode(row.merger || 'chosen by verdicts'));
1568
+ merge.appendChild(seat); seats.appendChild(merge);
1569
+ }
1570
+ left.appendChild(seats);
1441
1571
  var t = row.together;
1442
1572
  if (t) {
1443
1573
  var lim = Object.keys(t.limits || {}).filter(function (k) { return t.limits[k]; })
1444
1574
  .map(function (k) { return t.limits[k].toLocaleString() + ' ' + k; });
1445
- left.appendChild(el('div', {class: 'base'}, 'Together: ' +
1446
- (t.context_window ? 'prompts up to ' + Math.floor(t.context_window / 1000).toLocaleString() + 'k tokens' : 'context unknown') +
1447
- (t.context_unknown.length ? ' (unknown for ' + t.context_unknown.join(', ') + ')' : '') +
1448
- ' · all can: ' + (t.capabilities.join(', ') || 'nothing known in common') +
1449
- ' · ' + t.connections.length + ' connection' + (t.connections.length === 1 ? '' : 's') +
1450
- (lim.length ? ' · tightest ' + lim.join(', ') : '')));
1575
+ var can = t.capabilities.map(function (k) { return CAN[k] || k; });
1576
+ var n = t.connections.length + ' connection' + (t.connections.length === 1 ? '' : 's');
1577
+ left.appendChild(el('p', {class: 'together'},
1578
+ (t.context_window ? 'Prompts up to ' + tokens(t.context_window) + ' tokens' : 'Context window unknown') +
1579
+ (t.context_unknown.length ? ' (unknown for ' + t.context_unknown.join(', ') + '). ' : '. ') +
1580
+ (can.length ? 'All can ' + (can.length > 1 ? can.slice(0, -1).join(', ') + ' and ' : '') + can[can.length - 1] + '. '
1581
+ : 'Nothing known they can all do. ') +
1582
+ (lim.length ? 'Tightest limit ' + lim.join(', ') + ' across ' + n + '.' : 'No limits known across ' + n + '.')));
1451
1583
  }
1452
1584
  box.appendChild(left);
1453
1585
  box.appendChild(el('span', {class: 'open-hint', 'aria-hidden': 'true'}, 'Open ›'));
@@ -1544,6 +1676,290 @@
1544
1676
  }).catch(function (err) { $('v-errors').textContent = String(err); });
1545
1677
  });
1546
1678
 
1679
+ // ---- Quota: a tile per provider (serve.quota_tiles, metrics/quotaview.py) ----
1680
+ // Each tile takes its provider's shape: groups of windows (a 5-hour and a
1681
+ // weekly limit boxed together), one pool (OpenRouter's free requests a day,
1682
+ // a bar per key) or, where nothing is stated, each model's last day. A
1683
+ // meter is ten segments filled like a bar: 85% is eight and a half.
1684
+ var QD = $('quota-data') ? JSON.parse($('quota-data').textContent) : null;
1685
+ var qBox = $('q-tiles');
1686
+ function qWhen(iso) {
1687
+ if (!iso) return '';
1688
+ var d = new Date(iso), s = (d - Date.now()) / 1000;
1689
+ if (s <= 0) return 'now';
1690
+ if (s < 86400) {
1691
+ var h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
1692
+ return 'in ' + (h ? h + 'h ' : '') + m + 'm';
1693
+ }
1694
+ if (s < 6 * 86400) return d.toLocaleDateString([], {weekday: 'short'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
1695
+ return d.toLocaleDateString([], {month: 'numeric', day: 'numeric'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
1696
+ }
1697
+ function qAgo(iso) {
1698
+ var s = (Date.now() - new Date(iso)) / 1000;
1699
+ 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
+ }
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'});
1704
+ for (var i = 0; i < 10; i++) {
1705
+ var s = el('span'), f = el('i');
1706
+ f.style.width = Math.round(Math.min(Math.max(left / 10 - i, 0), 1) * 100) + '%';
1707
+ s.appendChild(f); box.appendChild(s);
1708
+ }
1709
+ return box;
1710
+ }
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
+ function qNum(n) { return Number(n).toLocaleString(); }
1718
+ 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;
1725
+ }
1726
+ function qResting(m) { return m.until && new Date(m.until) > Date.now(); }
1727
+ function qTile(t) {
1728
+ var node = el('article', {class: 'q-tile' + (t.out ? ' out' : ''), draggable: 'true', tabindex: '0',
1729
+ 'data-id': t.id, 'aria-label': t.name + ': drag, or use the arrow keys, to move it'});
1730
+ 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'));
1734
+ node.appendChild(head);
1735
+ node.appendChild(el('div', {class: 'q-tag mono'}, t.tag + (t.plan ? ' · ' + t.plan : '')));
1736
+ if (t.kind === 'groups') {
1737
+ t.groups.forEach(function (g) {
1738
+ var box = el('section', {class: g.label ? 'q-group' : 'q-group bare'});
1739
+ if (g.label) box.appendChild(el('h4', {}, g.label));
1740
+ g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1741
+ node.appendChild(box);
1742
+ });
1743
+ } else if (t.kind === 'pool') {
1744
+ 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);
1762
+ });
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'));
1765
+ }
1766
+ if (t.kind !== 'models') {
1767
+ t.models.filter(qResting).forEach(function (m) {
1768
+ node.appendChild(el('div', {class: 'q-rest'}, m.alias + ' resting · back ' + qWhen(m.until)));
1769
+ });
1770
+ }
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)); });
1773
+ var msg = el('div', {class: 'q-msg', 'aria-live': 'polite'});
1774
+ node.appendChild(msg);
1775
+ var foot = el('div', {class: 'q-foot'});
1776
+ 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)'});
1778
+ rb.innerHTML = '&#x21bb; Refresh';
1779
+ rb.addEventListener('click', function () { qAct('refresh', t.id, node); });
1780
+ 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
+ var mb = el('button', {class: 'btn small', type: 'button'}, 'Models ');
1787
+ mb.appendChild(el('span', {class: 'chip ' + (t.live === t.bench ? 'ok' : t.live ? 'warn' : 'err')}, t.live + '/' + t.bench));
1788
+ mb.title = t.live + ' of the ' + t.bench + ' bench models on it are free to call';
1789
+ mb.addEventListener('click', function () { qModels(t); });
1790
+ foot.appendChild(mb);
1791
+ node.appendChild(foot);
1792
+ return node;
1793
+ }
1794
+ function qAct(which, id, node) {
1795
+ 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) {
1798
+ if (!r.ok) { msg.className = 'q-msg errors'; msg.textContent = r.errors.join('; '); return; }
1799
+ var i = QD.tiles.findIndex(function (t) { return t.id === id; });
1800
+ if (r.tile && i >= 0) {
1801
+ QD.tiles[i] = r.tile;
1802
+ var fresh = qTile(r.tile);
1803
+ node.replaceWith(fresh);
1804
+ if (r.note) { var m = fresh.querySelector('.q-msg'); m.className = 'q-msg muted'; m.textContent = r.note; }
1805
+ }
1806
+ drawCharts();
1807
+ }).catch(function (e) { msg.className = 'q-msg errors'; msg.textContent = String(e); });
1808
+ }
1809
+ function qModels(t) {
1810
+ $('q-models-h').textContent = t.name + ': ' + t.bench + ' on the bench';
1811
+ $('q-models-sub').textContent = t.live + ' free to call now · the last 24 hours of calls';
1812
+ var list = $('q-models-list'); list.textContent = '';
1813
+ var tb = el('table'), hd = el('tr');
1814
+ ['Model', 'Now', 'Last 24 h', 'Last answered', 'Known limit'].forEach(function (h) { hd.appendChild(el('th', {}, h)); });
1815
+ tb.appendChild(el('thead')).appendChild(hd);
1816
+ var body = tb.appendChild(el('tbody'));
1817
+ t.models.forEach(function (m) {
1818
+ var tr = el('tr');
1819
+ tr.appendChild(el('td', {class: 'mono'}, m.alias));
1820
+ var now = el('td'); now.appendChild(el('span', {class: 'chip ' + (qResting(m) ? 'warn' : 'ok')}, qResting(m) ? 'resting · ' + qWhen(m.until) : 'ready'));
1821
+ tr.appendChild(now);
1822
+ tr.appendChild(el('td', {}, m.ok + ' ok · ' + m.failed + ' failed'));
1823
+ tr.appendChild(el('td', {}, m.last_ok ? qAgo(m.last_ok) : '—'));
1824
+ tr.appendChild(el('td', {}, m.limit || '—'));
1825
+ body.appendChild(tr);
1826
+ });
1827
+ list.appendChild(tb);
1828
+ $('q-models').showModal();
1829
+ }
1830
+ function qDefault(a, b) {
1831
+ return (b.out - a.out) || ((a.next_reset || '9999') < (b.next_reset || '9999') ? -1 : (a.next_reset || '9999') > (b.next_reset || '9999') ? 1 : 0)
1832
+ || (a.id < b.id ? -1 : 1);
1833
+ }
1834
+ function qDraw() {
1835
+ qBox.textContent = '';
1836
+ QD.tiles.forEach(function (t) { qBox.appendChild(qTile(t)); });
1837
+ if (!QD.tiles.length) qBox.appendChild(el('p', {class: 'muted'}, 'No model on the bench yet.'));
1838
+ }
1839
+ function qSaveOrder() {
1840
+ var ids = Array.prototype.map.call(qBox.querySelectorAll('.q-tile'), function (n) { return n.dataset.id; });
1841
+ var at = {}; ids.forEach(function (id, i) { at[id] = i; });
1842
+ QD.tiles.sort(function (a, b) { return at[a.id] - at[b.id]; });
1843
+ post('/api/quota/order', {order: ids}).then(function (r) {
1844
+ $('q-done').textContent = r.ok ? 'Order kept.' : ''; $('q-errors').textContent = r.ok ? '' : r.errors.join('; ');
1845
+ });
1846
+ }
1847
+ if (QD && qBox) {
1848
+ qDraw();
1849
+ $('q-models-close').addEventListener('click', function () { $('q-models').close(); });
1850
+ var dragged = null;
1851
+ qBox.addEventListener('dragstart', function (e) {
1852
+ dragged = e.target.closest && e.target.closest('.q-tile');
1853
+ if (!dragged) return;
1854
+ dragged.classList.add('dragging');
1855
+ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragged.dataset.id);
1856
+ });
1857
+ qBox.addEventListener('dragover', function (e) {
1858
+ if (!dragged) return;
1859
+ e.preventDefault();
1860
+ var over = e.target.closest && e.target.closest('.q-tile');
1861
+ if (!over || over === dragged) return;
1862
+ var r = over.getBoundingClientRect();
1863
+ var after = e.clientX > r.left + r.width / 2;
1864
+ over.parentNode.insertBefore(dragged, after ? over.nextSibling : over);
1865
+ });
1866
+ qBox.addEventListener('dragend', function () {
1867
+ if (!dragged) return;
1868
+ dragged.classList.remove('dragging'); dragged = null; qSaveOrder();
1869
+ });
1870
+ qBox.addEventListener('keydown', function (e) {
1871
+ var t = e.target;
1872
+ if (!t.classList || !t.classList.contains('q-tile')) return;
1873
+ var back = e.key === 'ArrowLeft' || e.key === 'ArrowUp', fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown';
1874
+ if (!back && !fwd) return;
1875
+ e.preventDefault();
1876
+ var sib = back ? t.previousElementSibling : t.nextElementSibling;
1877
+ if (!sib) return;
1878
+ qBox.insertBefore(t, back ? sib : sib.nextSibling);
1879
+ t.focus(); qSaveOrder();
1880
+ });
1881
+ $('q-reorder').addEventListener('click', function () {
1882
+ QD.tiles.sort(qDefault); qDraw();
1883
+ post('/api/quota/order', {order: []}).then(function (r) { $('q-done').textContent = r.ok ? 'Sorted: out first, then the soonest reset.' : ''; });
1884
+ });
1885
+ $('q-all').addEventListener('click', function () {
1886
+ var live = QD.tiles.filter(function (t) { return t.refresh === 'live'; });
1887
+ $('q-done').textContent = 'Refreshing ' + live.length + '…';
1888
+ live.reduce(function (p, t) {
1889
+ return p.then(function () {
1890
+ var node = qBox.querySelector('.q-tile[data-id="' + CSS.escape(t.id) + '"]');
1891
+ return node ? qAct('refresh', t.id, node) : null;
1892
+ });
1893
+ }, Promise.resolve()).then(function () { $('q-done').textContent = 'Refreshed ' + live.length + '.'; });
1894
+ });
1895
+ }
1896
+
1897
+ // The Quota page's two charts, in the theme's colours like the Benchmarks ones.
1898
+ function drawQuotaCharts(tok, style, width) {
1899
+ if (!QD) return;
1900
+ if (!window.Plot) {
1901
+ ['chart-sent', 'chart-resets'].forEach(function (id) {
1902
+ if ($(id)) $(id).textContent = 'Charts need Observable Plot from cdn.jsdelivr.net, which did not load.';
1903
+ });
1904
+ return;
1905
+ }
1906
+ var ink = tok('--ink', '#24331f'), good = tok('--ok', '#327a16'), warn = tok('--warn', '#7a4d00'), line = tok('--line', '#e4dfd2');
1907
+ var sent = $('chart-sent');
1908
+ if (sent) {
1909
+ var R = QD.sent.slice().sort(function (a, b) { return b.month - a.month; });
1910
+ sent.textContent = '';
1911
+ var lg = el('div', {class: 'q-legend'});
1912
+ [['This month, requests', 0.3], ['Last 24 hours, requests', 1]].forEach(function (k) {
1913
+ var s = el('span'), sw = el('i'); sw.style.background = good; sw.style.opacity = k[1];
1914
+ s.appendChild(sw); s.appendChild(document.createTextNode(k[0])); lg.appendChild(s);
1915
+ });
1916
+ sent.appendChild(lg);
1917
+ sent.appendChild(R.length ? Plot.plot({
1918
+ width: width(), height: 22 * R.length + 60, style: style, marginLeft: 120, marginRight: 90,
1919
+ x: {label: 'Requests →', grid: true}, y: {domain: R.map(function (r) { return r.provider; }), label: null},
1920
+ marks: [
1921
+ Plot.barX(R, {x: 'month', y: 'provider', fill: good, fillOpacity: 0.3,
1922
+ title: function (r) { return r.provider + '\nthis month: ' + qNum(r.month) + '\nlast 24 hours: ' + qNum(r.day); }}),
1923
+ Plot.barX(R, {x: 'day', y: 'provider', fill: good, insetTop: 4, insetBottom: 4}),
1924
+ Plot.text(R, {x: 'month', y: 'provider', text: function (r) { return qNum(r.day) + ' / ' + qNum(r.month); }, dx: 6, textAnchor: 'start', fill: ink}),
1925
+ Plot.ruleX([0], {stroke: ink})
1926
+ ]}) : el('p', {class: 'hint'}, 'No call in the ledger this month.'));
1927
+ }
1928
+ var res = $('chart-resets');
1929
+ if (res) {
1930
+ var now = Date.now(), end = now + 7 * 86400e3, dots = [], bars = [];
1931
+ QD.tiles.forEach(function (t) {
1932
+ var wins = [];
1933
+ t.groups.forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: (g.label ? g.label + ' · ' : '') + w.label, at: w.reset_at}); }); });
1934
+ t.pool.forEach(function (p) { if (p.reset_at) wins.push({label: p.label, at: p.reset_at, daily: true}); });
1935
+ wins.forEach(function (w) {
1936
+ var at = +new Date(w.at), daily = w.daily || / day$/.test(w.label);
1937
+ for (; at && at <= end; at += 86400e3) {
1938
+ if (at >= now) dots.push({tile: t.name, at: new Date(at), what: w.label});
1939
+ if (!daily) break;
1940
+ }
1941
+ });
1942
+ t.models.filter(qResting).forEach(function (m) {
1943
+ bars.push({tile: t.name, x1: new Date(now), x2: new Date(Math.min(+new Date(m.until), end)), what: m.alias + ' resting until ' + new Date(m.until).toLocaleString()});
1944
+ });
1945
+ });
1946
+ var names = QD.tiles.map(function (t) { return t.name; }).filter(function (n) {
1947
+ return dots.some(function (d) { return d.tile === n; }) || bars.some(function (b) { return b.tile === n; });
1948
+ });
1949
+ res.textContent = '';
1950
+ res.appendChild(names.length ? Plot.plot({
1951
+ width: width(), height: 26 * names.length + 60, style: style, marginLeft: 120,
1952
+ x: {domain: [new Date(now), new Date(end)], label: null, grid: true}, y: {domain: names, label: null},
1953
+ marks: [
1954
+ Plot.barX(bars, {x1: 'x1', x2: 'x2', y: 'tile', fill: warn, fillOpacity: 0.35, insetTop: 6, insetBottom: 6, title: 'what'}),
1955
+ Plot.dot(dots, {x: 'at', y: 'tile', r: 4, fill: good, stroke: ink, strokeWidth: 0.5,
1956
+ title: function (d) { return d.what + ' resets ' + d.at.toLocaleString(); }}),
1957
+ Plot.ruleX([new Date(now)], {stroke: tok('--err', '#a12b2b'), strokeWidth: 1.5}),
1958
+ Plot.ruleY(names, {stroke: line, strokeOpacity: 0.5})
1959
+ ]}) : el('p', {class: 'hint'}, 'No reset or rest known for the next 7 days.'));
1960
+ }
1961
+ }
1962
+
1547
1963
  // ---- Benchmarks charts: Observable Plot, data from serve.charts_data ------
1548
1964
  // A function, not run once: a theme switched on Settings redraws them in its colours.
1549
1965
  function drawCharts() {
@@ -1566,6 +1982,7 @@
1566
1982
  var col = document.querySelector('.panes') || document.querySelector('main');
1567
1983
  return Math.max(520, (col.clientWidth || 900) - 50);
1568
1984
  }
1985
+ drawQuotaCharts(tok, style, width);
1569
1986
  function put(id, make) {
1570
1987
  var box = $(id);
1571
1988
  if (!box) return;