@wendellresearch-ai/benchwarmers 0.6.1 → 0.6.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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.1
1
+ 0.6.2
package/metrics/route.py CHANGED
@@ -148,12 +148,14 @@ def _full_tag_of(alias: str) -> str:
148
148
  import endpoints
149
149
  # Tags are keyed by the served name. A decision model is not in the llm
150
150
  # yaml, so its served name comes from config/decision-models.json.
151
- served = (_registered_names().get(alias)
152
- or (_json(DATA / "config" / "decision-models.json").get(alias) or {}).get("served")
153
- or alias)
151
+ decision = _json(DATA / "config" / "decision-models.json").get(alias) or {}
152
+ served = _registered_names().get(alias) or decision.get("served") or alias
154
153
  tag = _json(DATA / "config" / "provider-tags.json").get(served)
155
154
  if not tag:
156
- name = endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
155
+ # A decision model is in no registration either: its own endpoint is its `via`
156
+ # (Cloudflare's clef, onboarded from a direct cloudflare endpoint).
157
+ name = (endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
158
+ or decision.get("via") or decision.get("endpoint"))
157
159
  tag = name if name and not endpoints.hub(name) else None
158
160
  return tag or "?"
159
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
@@ -236,6 +236,11 @@ def owner(fake: str) -> None:
236
236
  assert tags["fast-1"] == "groq" and tags["llm7.codestral"] == "llm7-api" and tags["vendor/x:free"] == "openrouter", tags
237
237
  assert "never-read" not in json.dumps(tags)
238
238
  assert route.connection_of("fast-1") == "groq" and route.connection_of("x-free") == "openrouter"
239
+ # A decision model onboarded from the direct endpoint: in no registration, untagged, its `via` is its connection.
240
+ write("decision-models.json", {"dec-1": {"served": "vendor/decide:free", "endpoint": "openrouter", "via": "openrouter"},
241
+ "dec-hub": {"served": "hub-decide", "endpoint": "openwebui", "via": "openwebui"}})
242
+ assert route.connection_of("dec-1") == "openrouter" and route.connection_of("dec-hub") == "?"
243
+ (TMP / "config" / "decision-models.json").unlink()
239
244
 
240
245
  # The default hub is recorded whole; the direct endpoint, its registered models only.
241
246
  assert check_deprecations.served() == {"fast-1", "llm7.codestral", "fresh-2", "mercury-decide",
package/serve.py CHANGED
@@ -4956,11 +4956,12 @@ def move_card() -> str:
4956
4956
  """Settings' Export and import (export_zip, import_zip; bench.js)."""
4957
4957
  return ("<div class='panel form-panel'><h2>Export and import</h2><p class='muted'>Take this install to another"
4958
4958
  " machine, or keep a copy of it: one .zip file, imported on the other install's Settings page.</p>"
4959
- "<div class='pick'><label class='check'><input type='radio' name='x-scope' value='settings' checked"
4959
+ # No wrapper: `.pick` is the onboarding picker's 3-column grid row, and squeezed these into it.
4960
+ "<label class='check'><input type='radio' name='x-scope' value='settings' checked"
4960
4961
  " data-kept> <span><b>Settings only</b>: endpoints, collections, roles, model options, the schedule,"
4961
4962
  " gateway keys, the theme (<code>config/</code>)</span></label>"
4962
4963
  "<label class='check'><input type='radio' name='x-scope' value='all' data-kept> <span><b>Everything</b>:"
4963
- " the settings, and every result, history, log and saved answer the bench judges by</span></label></div>"
4964
+ " the settings, and every result, history, log and saved answer the bench judges by</span></label>"
4964
4965
  "<label class='check'><input type='checkbox' id='x-keys' data-kept> <span>With the endpoints' API keys."
4965
4966
  " The file then holds them: keep it like a password, and delete it once imported.</span></label>"
4966
4967
  "<div class='actions'><button class='btn' type='button' id='x-export'>Download export</button></div>"
@@ -5005,23 +5006,28 @@ def access_card() -> str:
5005
5006
  body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td>{esc(v)}</td>"
5006
5007
  f"<td class='mono set-by'>{'<br>'.join(esc(s) for s in src.split(', '))}</td></tr>"
5007
5008
  for k, v, src in rows)
5008
- running, disk = on_disk()
5009
- now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
5010
- f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
5011
5009
  return ("<div class='panel'><h2>Server and access</h2><p class='muted'>How this install is reached and who"
5012
5010
  " may call it. Set in the environment, read at start: change one and restart. Shown here, never"
5013
5011
  " set here, since a page that could change its own login could open itself to anyone. No secret"
5014
5012
  " is shown, only whether one is set.</p>"
5015
5013
  f"<table class='facts'><thead><tr>{th('Setting')}{th('Now')}{th('Set by')}</tr></thead>"
5016
- f"<tbody>{body}</tbody></table>"
5017
- # restart(): stopped and started again by serve.py's own parent process.
5018
- f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
5019
- " for an update or a setting"
5020
- " above, whatever runs it (a service, the launcher, a container or a terminal). It waits for"
5021
- " onboarding, a scheduled job and gateway calls in progress, or cuts them off if you insist; the"
5022
- " page comes back in a few seconds.</p>"
5023
- "<p><button class='btn' type='button' id='restart'>Restart Benchwarmers</button>"
5024
- " <span id='restart-out' role='status'></span></p></div>")
5014
+ f"<tbody>{body}</tbody></table>{restart_block('restart', 'a setting above')}</div>")
5015
+
5016
+
5017
+ def restart_block(id_: str, what: str) -> str:
5018
+ """The Restart heading, what runs now, and its button (bench.js restart()).
5019
+ On Settings (Server and access) and Info, each with its own ids, since both
5020
+ tabs can be in the page at once."""
5021
+ running, disk = on_disk()
5022
+ now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
5023
+ f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
5024
+ # restart(): stopped and started again by serve.py's own parent process.
5025
+ return (f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
5026
+ f" for an update or {what}, whatever runs it (a service, the launcher, a container or a terminal)."
5027
+ " It waits for onboarding, a scheduled job and gateway calls in progress, or cuts them off if you"
5028
+ " insist; the page comes back in a few seconds.</p>"
5029
+ f"<p><button class='btn' type='button' id='{id_}'>Restart Benchwarmers</button>"
5030
+ f" <span id='{id_}-out' role='status'></span></p>")
5025
5031
 
5026
5032
 
5027
5033
  RESTART = 75 # the exit code serve.py's parent reads as "start me again"
@@ -5337,7 +5343,8 @@ def models_page() -> str:
5337
5343
  # Every model, decision models too (connections_view is chat models only,
5338
5344
  # for what a collection may hold): they are on the bench like any other.
5339
5345
  by: dict[str, list[str]] = {}
5340
- for a in model_directory():
5346
+ directory = model_directory()
5347
+ for a in directory:
5341
5348
  by.setdefault(catalog.connection_of(a), []).append(a)
5342
5349
  for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
5343
5350
  rows = []
@@ -5348,6 +5355,9 @@ def models_page() -> str:
5348
5355
  ep = through.get(a, "?")
5349
5356
  kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
5350
5357
  if a in decisions else "")
5358
+ if directory[a].get("ad_hoc"):
5359
+ kind += " " + chip("ad hoc", "", "ad hoc only: never chosen by the bench on its own, only when you"
5360
+ " name it (a collection member, with, --model)")
5351
5361
  rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
5352
5362
  f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
5353
5363
  f"<td data-sort='{esc(ep)}'>{chip(ep, 'via', f'reached through the {ep} endpoint')}</td>"
@@ -5604,6 +5614,7 @@ def info_page() -> str:
5604
5614
  ("Theme", saved_theme())]]
5605
5615
  body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td class='mono'>{v}</td></tr>" for k, v in rows)
5606
5616
  return (f"<div class='panel'><h2>Info</h2><table class='facts'><tbody>{body}</tbody></table>"
5617
+ f"{restart_block('restart-info', 'a setting')}"
5607
5618
  "<p class='muted'>Benchwarmers · by Wendell Research · MIT licence"
5608
5619
  " (<code>LICENSE</code>, credits in <code>NOTICE</code>). Every response carries"
5609
5620
  " <code>X-Benchwarmers-Version</code>.</p></div>")
@@ -5668,8 +5679,7 @@ def editor() -> str:
5668
5679
  + "".join(f"<label>{k} <input type='number' min='1' data-k='{k}' placeholder='—'></label>"
5669
5680
  for k in ("rpm", "rpd", "tpm", "tpd"))
5670
5681
  + "</div><div class='src' id='s-limits'></div></div>"
5671
- "</div><div class='errors' id='f-errors' role='alert'></div><div class='done' id='f-done'></div>"
5672
- "<div class='actions'><button class='btn' id='f-save' type='button'>Save facts</button></div>"
5682
+ "</div><div class='errors' id='f-errors' role='alert'></div>"
5673
5683
  "</details>"
5674
5684
  # How the bench calls it (serve.save_model_options): every caller, gateway, suite and probes.
5675
5685
  "<details id='m-opts' class='facts-box'><summary>Call options</summary>"
@@ -5679,8 +5689,7 @@ def editor() -> str:
5679
5689
  " makes to it. A reasoning model thinks out of the same budget and answers with an empty string"
5680
5690
  " when it runs out, which looks like a dead model: raise it then. Blank leaves it to the"
5681
5691
  " endpoint. <span id='co-ceiling'></span></p>"
5682
- "<div class='errors' id='co-errors' role='alert'></div><div class='done' id='co-done'></div>"
5683
- "<div class='actions'><button class='btn' id='co-save' type='button'>Save options</button></div>"
5692
+ "<div class='errors' id='co-errors' role='alert'></div>"
5684
5693
  "</details>"
5685
5694
  "<label class='check'><input type='checkbox' id='m-adhoc'> Ad hoc only</label>"
5686
5695
  "<p class='rules'>Never chosen by the bench on its own: no explorer seat, no router"
package/ui/bench.js CHANGED
@@ -55,30 +55,33 @@
55
55
  askD.addEventListener('close', function () { answer(false); }); // Escape
56
56
 
57
57
  // ---- restart (serve.restart): the page reloads once the new one answers ----
58
- function restart(force) {
59
- var rs = $('restart'), out = $('restart-out');
58
+ // One button on Settings (restart) and one on Info (restart-info), each with its own status line.
59
+ function restart(id, force) {
60
+ var rs = $(id), out = $(id + '-out');
60
61
  rs.disabled = true; out.textContent = 'Restarting…';
61
62
  post('/api/restart', {force: force}).then(function (res) {
62
- if (res.ok) return back(Date.now());
63
+ if (res.ok) return back(out, Date.now());
63
64
  rs.disabled = false; out.textContent = '';
64
65
  if (!res.busy) { listErrors(out, res.errors); return; }
65
66
  ask('Still in progress: ' + res.errors.join('; ') + '. Restart anyway and cut them off?', 'Restart anyway', true)
66
- .then(function (yes) { if (yes) restart(true); });
67
+ .then(function (yes) { if (yes) restart(id, true); });
67
68
  }).catch(function (err) { rs.disabled = false; out.textContent = String(err); });
68
69
  }
69
70
  // The old one stops half a second after answering; /health answers again once the new one is up.
70
- function back(since) {
71
+ function back(out, since) {
71
72
  setTimeout(function () {
72
73
  fetch('/health', {cache: 'no-store'}).then(function (r) { if (!r.ok) throw r.status; location.reload(); })
73
74
  .catch(function () {
74
- if (Date.now() - since < 120000) back(since);
75
- else $('restart-out').textContent = 'Not back after two minutes: look at whatever runs it.';
75
+ if (Date.now() - since < 120000) back(out, since);
76
+ else out.textContent = 'Not back after two minutes: look at whatever runs it.';
76
77
  });
77
78
  }, 1500);
78
79
  }
79
- on('click', 'restart', function () {
80
- ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
81
- .then(function (yes) { if (yes) restart(false); });
80
+ ['restart', 'restart-info'].forEach(function (id) {
81
+ on('click', id, function () {
82
+ ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
83
+ .then(function (yes) { if (yes) restart(id, false); });
84
+ });
82
85
  });
83
86
 
84
87
  // ---- Export and import (serve.export_zip, import_zip) ----------------
@@ -490,7 +493,7 @@
490
493
  reason: $('m-reason'), remove: $('m-remove'), onboard: $('m-onboard'),
491
494
  qtypes: $('m-qtypes'), aliasWhy: $('m-alias-why'), roleHint: $('m-role-hint')};
492
495
  m.aliasWhy.dataset.chat = m.aliasWhy.textContent;
493
- var editing = null;
496
+ var editing = null, shown = {};
494
497
 
495
498
  function openModel(alias) {
496
499
  var model = D.models[alias], gone = (D.retired || {})[alias];
@@ -539,6 +542,8 @@
539
542
  + (hint.at || '').slice(0, 10) + '). Only a suggestion: the role is the one you pick above.' : '';
540
543
  m.save.disabled = false; m.save.textContent = 'Save';
541
544
  m.reason.value = ''; m.removeBox.open = false; m.remove.disabled = false; refresh();
545
+ // What the popup opened with: Save sends only the parts that differ from it.
546
+ shown = {model: JSON.stringify(modelBody()), facts: factsBody(), opts: optsBody()};
542
547
  }
543
548
  if (!md.open) md.showModal(); // open already: shown again as it now is (shownAgain)
544
549
  }
@@ -577,7 +582,7 @@
577
582
  var box = $('m-facts'), f = (D.facts || {})[alias], profile = $('m-profile');
578
583
  box.hidden = profile.hidden = decision || !f;
579
584
  profile.textContent = '';
580
- $('f-errors').textContent = ''; $('f-done').textContent = '';
585
+ $('f-errors').textContent = '';
581
586
  if (box.hidden) return;
582
587
  var src = f.sources || {}, mine = function (k) { return src[k] === 'you'; };
583
588
  // At a glance, as models.dev shows a model: what goes in, what comes out.
@@ -621,40 +626,25 @@
621
626
  var lsrc = Object.keys(src).filter(function (k) { return k.indexOf('limits') === 0; }).map(function (k) { return src[k]; });
622
627
  $('s-limits').textContent = lsrc.length ? lsrc.join(' · ') : 'none known';
623
628
  }
624
- $('f-save').addEventListener('click', function () {
625
- if (!editing) return;
629
+ // The facts as typed, as /api/model-facts takes them; null when the box is not shown.
630
+ function factsBody() {
631
+ if ($('m-facts').hidden) return null;
626
632
  var facts = {limits: {}};
627
633
  FACT_NUM.forEach(function (k) { if ($('f-' + k).value) facts[k] = parseInt($('f-' + k).value, 10); });
628
634
  FACT_CAP.forEach(function (k) { var v = $('f-' + k).value; if (v) facts[k] = v === 'yes'; });
629
635
  if ($('f-knowledge_cutoff').value.trim()) facts.knowledge_cutoff = $('f-knowledge_cutoff').value.trim();
630
636
  document.querySelectorAll('#f-limits input').forEach(function (b) { if (b.value) facts.limits[b.dataset.k] = parseInt(b.value, 10); });
631
- $('f-errors').textContent = ''; $('f-done').textContent = '';
632
- var alias = editing;
633
- post('/api/model-facts', {alias: alias, facts: facts}).then(function (r) {
634
- if (!r.ok) { listErrors($('f-errors'), r.errors); return; }
635
- // The tables that show facts (At a glance, Models, Quotas) follow once the popup closes (live).
636
- shownAgain(alias, 'f-done', 'Saved. The tables show it once this closes.');
637
- });
638
- });
637
+ return JSON.stringify(facts);
638
+ }
639
639
  // ---- call options: how the bench asks this model (serve.save_model_options)
640
640
  function fillOptions(alias, decision) {
641
641
  var o = (D.options || {})[alias] || {}, ceiling = ((D.facts || {})[alias] || {}).max_output;
642
642
  $('m-opts').hidden = decision;
643
- $('co-errors').textContent = ''; $('co-done').textContent = '';
643
+ $('co-errors').textContent = '';
644
644
  $('co-max').value = o.max_tokens || ''; $('co-max').placeholder = "the endpoint's default";
645
645
  $('co-ceiling').textContent = ceiling ? 'At most its max output, ' + Number(ceiling).toLocaleString() + '.' : '';
646
646
  }
647
- $('co-save').addEventListener('click', function () {
648
- if (!editing) return;
649
- var v = $('co-max').value.trim();
650
- $('co-errors').textContent = ''; $('co-done').textContent = 'Saving…'; // the llm CLI is told too: seconds
651
- var alias = editing;
652
- post('/api/model-options', {alias: alias, max_tokens: v ? Number(v) : null}).then(function (r) {
653
- if (!r.ok) { $('co-done').textContent = ''; listErrors($('co-errors'), r.errors); return; }
654
- ownChange(); // only this popup shows them
655
- shownAgain(alias, 'co-done', 'Saved' + (r.note ? '; ' + r.note : '') + '.');
656
- });
657
- });
647
+ function optsBody() { return $('m-opts').hidden ? null : $('co-max').value.trim(); }
658
648
 
659
649
  function refresh() {
660
650
  m.danger.hidden = !(editing && m.alias.value.trim() !== editing);
@@ -690,22 +680,57 @@
690
680
  then(res);
691
681
  };
692
682
  }
693
- m.save.addEventListener('click', function () {
694
- if (!editing) return;
695
- var body = {alias: editing, new_alias: m.alias.value.trim(), new_role: m.role.value, new_ad_hoc: m.adhoc.checked};
683
+ function modelBody() {
684
+ var body = {new_alias: m.alias.value.trim(), new_role: m.role.value, new_ad_hoc: m.adhoc.checked};
696
685
  if (!m.qtypes.hidden) {
697
686
  body.new_question_types = Array.prototype.filter.call(m.qtypes.querySelectorAll('input'),
698
687
  function (c) { return c.checked; }).map(function (c) { return c.value; });
699
688
  }
700
- (body.new_alias === editing ? Promise.resolve(true)
701
- : ask('Rename ' + editing + ' to ' + body.new_alias + '? Every record keyed by the old alias follows it.', 'Rename')
689
+ return body;
690
+ }
691
+ // The one Save: facts, then call options, then the model itself (alias, role, ad hoc,
692
+ // question types), each only if changed. The model goes last: a rename moves the
693
+ // facts and options saved under the old alias with it. The first refusal stops it.
694
+ m.save.addEventListener('click', function () {
695
+ if (!editing) return;
696
+ var alias = editing, body = modelBody(), facts = factsBody(), opts = optsBody(), said = [];
697
+ var steps = [];
698
+ if (facts !== shown.facts) steps.push(function () {
699
+ return post('/api/model-facts', {alias: alias, facts: JSON.parse(facts)}).then(function (r) {
700
+ if (!r.ok) { listErrors($('f-errors'), r.errors); $('m-facts').open = true; throw 'refused'; }
701
+ said.push('model facts');
702
+ });
703
+ });
704
+ if (opts !== shown.opts) steps.push(function () { // the llm CLI is told too: seconds
705
+ return post('/api/model-options', {alias: alias, max_tokens: opts ? Number(opts) : null}).then(function (r) {
706
+ if (!r.ok) { listErrors($('co-errors'), r.errors); $('m-opts').open = true; throw 'refused'; }
707
+ ownChange(); // only this popup shows them
708
+ said.push('call options' + (r.note ? ' (' + r.note + ')' : ''));
709
+ });
710
+ });
711
+ if (JSON.stringify(body) !== shown.model) steps.push(function () {
712
+ body.alias = alias;
713
+ return post('/api/model', body).then(function (r) {
714
+ if (!r.ok) { listErrors(m.errors, r.errors); throw 'refused'; }
715
+ said.push('the model (' + done(r).replace(/^Done: |\.$/g, '') + ')');
716
+ if (body.new_alias !== alias) { m.title.textContent = body.new_alias; refreshPanes(); }
717
+ });
718
+ });
719
+ m.errors.textContent = ''; m.done.textContent = ''; $('f-errors').textContent = ''; $('co-errors').textContent = '';
720
+ if (!steps.length) { m.done.textContent = 'Nothing changed.'; return; }
721
+ (body.new_alias === alias ? Promise.resolve(true)
722
+ : ask('Rename ' + alias + ' to ' + body.new_alias + '? Every record keyed by the old alias follows it.', 'Rename')
702
723
  ).then(function (yes) {
703
724
  if (!yes) return;
704
- m.save.disabled = true; m.save.textContent = 'Saving…'; m.errors.textContent = '';
705
- post('/api/model', body).then(after(m.save, 'Save', function (res) {
706
- if (body.new_alias !== body.alias) { m.title.textContent = body.new_alias; refreshPanes(); }
707
- shownAgain(body.new_alias, 'm-done', done(res));
708
- })).catch(function (err) { m.errors.textContent = String(err); m.save.disabled = false; m.save.textContent = 'Save'; });
725
+ m.save.disabled = true; m.save.textContent = 'Saving…';
726
+ steps.reduce(function (p, step) { return p.then(step); }, Promise.resolve()).then(function () {
727
+ // The tables that show facts follow once the popup closes (live).
728
+ shownAgain(body.new_alias, 'm-done', 'Saved: ' + said.join(' · ') + '.');
729
+ }).catch(function (err) {
730
+ if (err !== 'refused') m.errors.textContent = String(err);
731
+ if (said.length) m.done.textContent = 'Saved before that: ' + said.join(' · ') + '.';
732
+ m.save.disabled = false; m.save.textContent = 'Save';
733
+ });
709
734
  });
710
735
  });
711
736
  // The confirm is the last word: the popup closes at once and the page