@wendellresearch-ai/benchwarmers 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/serve.py CHANGED
@@ -554,7 +554,7 @@ def _return_to_collections(alias: str, was: dict, stamp: str, who: str) -> list[
554
554
  Returns the names it went back into."""
555
555
  if not was or bench_collections.unreadable(bench_collections.FILE):
556
556
  return []
557
- data, cap = bench_collections.load(), bench_collections.settings()["max_calls"]
557
+ data, s = bench_collections.load(), bench_collections.settings()
558
558
  back = []
559
559
  for name, fields in was.items():
560
560
  c = data.get(name)
@@ -562,7 +562,7 @@ def _return_to_collections(alias: str, was: dict, stamp: str, who: str) -> list[
562
562
  continue
563
563
  old = json.loads(json.dumps(c))
564
564
  members = c.get("members") or []
565
- if "members" in fields and alias not in members and len(members) < cap:
565
+ if "members" in fields and alias not in members and len(members) < bench_collections.limits(c, s)["max_calls"]:
566
566
  c["members"] = members + [alias]
567
567
  if "merger" in fields and not c.get("merger"):
568
568
  c["merger"] = alias
@@ -698,10 +698,11 @@ def dismiss_alert(key: str) -> dict:
698
698
 
699
699
 
700
700
  def retry_model(alias: str, who: str = "ui") -> dict:
701
- """One fresh call to a model on a retiring alert. An answer is written to
701
+ """One fresh call to a model on Action needed. An answer is written to
702
702
  perf/history.csv as evidence (scripts/auto_retire.py reads it, so the
703
- clock resets) and the alert is cleared now rather than at the next 5am
704
- check. A failure writes nothing: the daily probe keeps the record.
703
+ clock resets) and its alerts are cleared now rather than at the next 5am
704
+ check (alerts.answered). A failure writes nothing: the daily probe keeps
705
+ the record.
705
706
 
706
707
  One request, never the client's retries: a 429's stated wait (UnoRouter's
707
708
  60 s) then a second request spent a minute and another call of a 1-a-minute
@@ -725,12 +726,10 @@ def retry_model(alias: str, who: str = "ui") -> dict:
725
726
  f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}",
726
727
  0.0, 1, 0, 0, 0, "", "retry from the dashboard", ""])
727
728
  import alerts as _alerts
728
- served = {a: s for s, a in names.items()}.get(alias, "")
729
- # Exactly this model's: auto_retire.py raises model:pending:<served> with its alias.
730
- cleared = [k for k, a in _alerts.active().items()
731
- if a.get("kind") == "retiring" and (a.get("alias") == alias or k == f"model:pending:{served}")]
732
- for key in cleared:
733
- _alerts.clear(key)
729
+ # Every alert the answer disproves, not only a retiring one: 2026-11-02,
730
+ # big-pickle answered and stayed on Action needed as "healthy", its
731
+ # unrecognised-failure alert still open.
732
+ cleared = _alerts.answered(alias, {a: s for s, a in names.items()}.get(alias, ""))
734
733
  _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, alias,
735
734
  "retried", None, f"answered in {seconds:.1f}s")
736
735
  return {"ok": True, "errors": [], "sync": "",
@@ -1511,19 +1510,25 @@ LONG_CONTEXT = 128_000 # the long-context tag: members known to hold at
1511
1510
 
1512
1511
 
1513
1512
  def save_collection(name: str, members, shape: str, description: str, tags=None,
1514
- reply: str = "all", merger: str = "", who: str = "ui") -> dict:
1513
+ reply: str = "all", merger: str = "", ad_hoc: bool = False, who: str = "ui",
1514
+ max_calls=None, max_explorers=None) -> dict:
1515
1515
  """Create or replace a collection. Every member must be a chat model on the
1516
1516
  bench (a decision model cannot answer a prompt), no more than max_calls.
1517
1517
  The description and tags are what an orchestrator reads on /v1/models to
1518
- choose a collection, so they say what it is for."""
1518
+ choose a collection, so they say what it is for. `ad_hoc` takes it off
1519
+ /v1/models: it runs only when a request names it. `max_calls` and
1520
+ `max_explorers`: its own limits; None follows the gateway settings."""
1519
1521
  # The name is what follows "bench/"; one typed with the prefix is taken as meant.
1520
1522
  name, shape = (name or "").strip().removeprefix(bench_collections.PREFIX), (shape or "").strip()
1521
1523
  description = " ".join((description or "").split())
1522
1524
  if isinstance(tags, str):
1523
1525
  tags = tags.split(",")
1524
1526
  tags = list(dict.fromkeys(t.strip().lower() for t in (tags or []) if isinstance(t, str) and t.strip()))
1525
- s = bench_collections.settings()
1526
- errors = []
1527
+ own = {k: v for k, v in (("max_calls", max_calls), ("max_explorers", max_explorers)) if v is not None}
1528
+ s = bench_collections.limits(own)
1529
+ errors = [f"{k.replace('_', ' ')} must be a whole number from {bench_collections.BOUNDS[k][0]} to"
1530
+ f" {bench_collections.BOUNDS[k][1]}, or empty for the gateway setting"
1531
+ for k, v in own.items() if not bench_collections.limit_ok(k, v)]
1527
1532
  if not bench_collections.NAME.match(name):
1528
1533
  errors.append("name: 2-32 lower-case letters, digits or '-', saying what it is for,"
1529
1534
  " e.g. reviewers; callers ask for bench/<name>")
@@ -1534,7 +1539,7 @@ def save_collection(name: str, members, shape: str, description: str, tags=None,
1534
1539
  if not members:
1535
1540
  errors.append("a collection needs at least one member")
1536
1541
  if len(members) > s["max_calls"]:
1537
- errors.append(f"at most {s['max_calls']} members (max calls per request, in settings)")
1542
+ errors.append(f"at most {s['max_calls']} members (max calls per request)")
1538
1543
  unknown = [m for m in members if m not in known]
1539
1544
  if unknown:
1540
1545
  errors.append(f"not chat models on the bench: {', '.join(unknown)}")
@@ -1558,7 +1563,7 @@ def save_collection(name: str, members, shape: str, description: str, tags=None,
1558
1563
  data = bench_collections.load()
1559
1564
  old = data.get(name)
1560
1565
  data[name] = {"members": members, "shape": shape, "description": description, "tags": tags,
1561
- "reply": reply, "merger": merger}
1566
+ "reply": reply, "merger": merger, **({"ad_hoc": True} if ad_hoc else {}), **own}
1562
1567
  _write_atomic(bench_collections.FILE, _dump(data))
1563
1568
  _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
1564
1569
  who, "", f"collection {name}", old, data[name])
@@ -1585,7 +1590,8 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
1585
1590
  if not isinstance(max_explorers, int) or isinstance(max_explorers, bool) or not 0 <= max_explorers <= 2:
1586
1591
  errors.append("max explorers must be 0, 1 or 2 (route.py seats at most two)")
1587
1592
  too_big = [n for n, c in bench_collections.load().items()
1588
- if isinstance(max_calls, int) and len(c.get("members", [])) > max_calls]
1593
+ if isinstance(max_calls, int) and len(c.get("members", [])) > max_calls
1594
+ and "max_calls" not in bench_collections.own_limits(c)]
1589
1595
  if too_big:
1590
1596
  errors.append(f"these collections have more members than {max_calls}: {', '.join(too_big)}")
1591
1597
  if bench_collections.unreadable(bench_collections.SETTINGS_FILE):
@@ -3132,8 +3138,8 @@ def _discover_rows() -> list[dict]:
3132
3138
  (Settings' free-only-in-their-own-app switches)."""
3133
3139
  import discover
3134
3140
  import sources
3135
- paths = [sources.CACHE / f"{n}.json" for n in sources.FEEDS] + [endpoints.YAML, endpoints.FILE,
3136
- endpoints.CONNECTIONS]
3141
+ paths = [sources.CACHE / f"{n}.json" for n in [*sources.FEEDS, discover.LISTS]] + [
3142
+ endpoints.YAML, endpoints.FILE, endpoints.CONNECTIONS]
3137
3143
  stamp = tuple(p.stat().st_mtime if p.exists() else 0 for p in paths)
3138
3144
  if _DISCOVER.get("stamp") != stamp:
3139
3145
  _DISCOVER.update(stamp=stamp, rows=discover.build())
@@ -3158,6 +3164,17 @@ def discover_model(provider: str, model: str) -> dict:
3158
3164
  return {"ok": True, "errors": errors, "model": row, "routes": routes}
3159
3165
 
3160
3166
 
3167
+ def discover_refresh(who: str = "ui") -> dict:
3168
+ """Discover's Refresh: every list fetched now (discover.refresh), for a
3169
+ model too new to have waited for the daily fetch. A list that would not
3170
+ come keeps its last copy and is said; the rest are still used."""
3171
+ import discover
3172
+ said = discover.refresh(force=True)
3173
+ _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "", "discover-refresh",
3174
+ None, "; ".join(said) or "every list fetched")
3175
+ return {"ok": True, "errors": said}
3176
+
3177
+
3161
3178
  def discover_card() -> str:
3162
3179
  """Models worth adding, from FreeLLMAPI and models.dev (metrics/discover.py),
3163
3180
  deduped. Read from the daily cache, so the page never waits on the network;
@@ -3174,7 +3191,7 @@ def discover_card() -> str:
3174
3191
  caps = [c for c, on in (("tools", r["tools"]), ("vision", r["vision"]),
3175
3192
  ("reasons", r["reasoning"])) if on]
3176
3193
  cost = r.get("cost") or {}
3177
- price = ("free" if r["free"] else
3194
+ price = ("free" if r["free"] else "unknown" if r["free"] is None else
3178
3195
  f"{cost.get('input', '?')} / {cost.get('output', '?')}" if cost else "paid")
3179
3196
  lim = r.get("limits") or {}
3180
3197
  lim_text = " · ".join([f"{v:,} {k}" for k, v in lim.items()]
@@ -3208,9 +3225,17 @@ def discover_card() -> str:
3208
3225
  f"<td>{add}</td></tr>")
3209
3226
  body.append("</tbody></table>")
3210
3227
  free = sum(1 for r in rows if r["free"])
3228
+ import discover
3229
+ import sources
3230
+ ages = [a for a in map(sources.age_s, [*sources.FEEDS, discover.LISTS]) if a is not None]
3211
3231
  toolbar = ("<div class='toolbar'><input type='search' data-filter placeholder='Filter by model,"
3212
3232
  " provider or reach' aria-label='Filter rows'>"
3213
- "<label><input type='checkbox' data-paid> Show paid (connected providers)</label></div>")
3233
+ "<label><input type='checkbox' data-paid> Show paid and unpriced (connected providers)</label>"
3234
+ "<button class='btn small' type='button' id='d-refresh' title='Fetch FreeLLMAPI, models.dev and"
3235
+ " every Open WebUI connection&#39;s own model list now, instead of waiting for the daily fetch'>"
3236
+ "Refresh lists</button><span class='muted' id='d-refreshed'>"
3237
+ + ("never fetched" if not ages else f"oldest list {max(ages) / 60:.0f} minutes old" if max(ages) < 5400
3238
+ else f"oldest list {max(ages) / 3600:.0f} hours old") + "</span></div>")
3214
3239
  key = """<dl>
3215
3240
  <dt>Rank</dt><dd>FreeLLMAPI's smartest-first order (1 is the smartest it lists); within
3216
3241
  each reach the list follows it. Hover for its fastest-first rank and size. A dash where
@@ -3223,7 +3248,8 @@ def discover_card() -> str:
3223
3248
  for its base URL); <em>other</em> a native API or an unvetted reseller.</dd>
3224
3249
  <dt>Price</dt><dd>Free if FreeLLMAPI lists it (a catalog of free tiers) or models.dev
3225
3250
  prices it at 0: models.dev shows the PAID price even where a free tier exists. Otherwise
3226
- input / output per million tokens.</dd>
3251
+ input / output per million tokens. <em>Unknown</em> where only the provider's own list has
3252
+ the model and it says nothing of price; those are behind <em>Show paid and unpriced</em>.</dd>
3227
3253
  <dt>Limits</dt><dd>The free tier's, from FreeLLMAPI: requests and tokens per minute (rpm,
3228
3254
  tpm) and per day (rpd, tpd), and roughly how many tokens a month that allows.</dd>
3229
3255
  <dt>New</dt><dd>First seen in either list in the last 7 days.</dd>
@@ -3241,11 +3267,13 @@ def discover_card() -> str:
3241
3267
  <dt>Sources</dt><dd>FreeLLMAPI's free catalog first: where it lists a model, its name,
3242
3268
  provider, context, tools, limits, ranks and quirks lead, and the popup links its
3243
3269
  freellmapi.co page. models.dev fills the rest and alone has the paid models, prices,
3244
- base URLs and modalities. A model one provider offers in both is shown once. Both are
3245
- refreshed at most daily.</dd>
3270
+ base URLs and modalities; the popup links its page for the model. A model one provider
3271
+ offers in both is shown once. Then each Open WebUI connection's own model list, for
3272
+ what the provider serves that neither catalog has yet. All are fetched daily;
3273
+ <em>Refresh lists</em> fetches them now.</dd>
3246
3274
  </dl>"""
3247
- return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from FreeLLMAPI"
3248
- f" and models.dev, deduped; {hidden:,} paid models on providers you have no route"
3275
+ return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from FreeLLMAPI,"
3276
+ f" models.dev and your providers' own lists, deduped; {hidden:,} paid models on providers you have no route"
3249
3277
  f" to are left out. Click a model for everything known about it and the routes it can be"
3250
3278
  f" added by.</p>{toolbar}"
3251
3279
  f"<div class='scroll'>{''.join(body)}</div>"
@@ -3287,7 +3315,7 @@ def collections_tab() -> str:
3287
3315
  " as <code>code</code> or <code>prose</code> the roles it does not admit; <code>free</code> hides"
3288
3316
  " the paid ones. Click a suggestion or type your own: comma-separated lower-case words, up to 8.</p>"
3289
3317
  # Not data-filter: that binds to the whole panel. The same match (any
3290
- # text, case-insensitive), on the member list only; ticked ones stay shown.
3318
+ # text, case-insensitive), on the member list only; the seats show who is ticked.
3291
3319
  "<label class='field' for='c-member-filter'>Members</label>"
3292
3320
  # The panel as it will sit, drawn as the Collections list draws it
3293
3321
  # (bench.js recount): a seat per ticked member, explorer seats dashed.
@@ -3298,6 +3326,15 @@ def collections_tab() -> str:
3298
3326
  "<option value=''>Every provider</option></select></div>"
3299
3327
  "<div id='c-members' class='members' role='group' aria-label='Members'></div>"
3300
3328
  "<p class='rules' id='c-count'></p>"
3329
+ "<div class='settings'>"
3330
+ "<div><label class='field' for='c-max-calls'>Max calls per request</label>"
3331
+ "<input id='c-max-calls' type='number' min='1' max='10'></div>"
3332
+ "<div><label class='field' for='c-max-explorers'>Max explorers</label>"
3333
+ "<input id='c-max-explorers' type='number' min='0' max='2'></div>"
3334
+ "</div>"
3335
+ "<p class='rules'>Empty follows the Gateway settings, shown greyed. A member out of quota stays"
3336
+ " on the collection and an explorer sits in for it until the quota is back. Max explorers 0"
3337
+ " means only the members ever run, stand-ins included.</p>"
3301
3338
  "<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
3302
3339
  "<p class='rules'>Which models may fill the explorer seats. Automatic asks a decision"
3303
3340
  " model to label each prompt when one is onboarded, else general.</p>"
@@ -3316,6 +3353,10 @@ def collections_tab() -> str:
3316
3353
  "<label class='field' for='c-desc'>Description</label>"
3317
3354
  "<textarea id='c-desc' rows='2' maxlength='300'"
3318
3355
  " placeholder='e.g. Adversarial code review: finding bugs and broken guarantees in a diff'></textarea>"
3356
+ "<label class='check'><input type='checkbox' id='c-ad-hoc'> Ad hoc: only when called by name</label>"
3357
+ "<p class='rules'>Left off <code>GET /v1/models</code> and the list agents choose from, so an"
3358
+ " agent picking for itself never spends it. For the expensive ones you decide to run: say"
3359
+ " <code>bench/&lt;name&gt;</code> and it runs. Not a lock: anyone who knows the name can call it.</p>"
3319
3360
  "<div class='errors' id='c-errors' role='alert'></div><div class='done' id='c-done'></div>"
3320
3361
  "<div class='actions'><button class='btn danger' id='c-delete' hidden>Delete collection</button>"
3321
3362
  "<span class='spacer'></span><button class='btn' id='c-close'>Close</button>"
@@ -3329,7 +3370,8 @@ def collections_tab() -> str:
3329
3370
  "<input id='g-max-explorers' type='number' min='0' max='2'></div>"
3330
3371
  "</div>"
3331
3372
  "<p class='rules'>Explorers = min(max explorers, max calls − members): with 5 and 2, a"
3332
- " 2- or 3-model collection gets 2, a 4-model one 1, a 5-model one none. A request that"
3373
+ " 2- or 3-model collection gets 2, a 4-model one 1, a 5-model one none. These apply to"
3374
+ " every collection that does not set its own in its dialog. A request that"
3333
3375
  " names no collection is refused with each one's description and tags, so the caller"
3334
3376
  " can choose.</p>"
3335
3377
  "<div class='errors' id='g-errors' role='alert'></div><div class='done' id='g-done'></div>"
@@ -4439,6 +4481,7 @@ class Handler(gateway.Handler):
4439
4481
  "/api/remove": lambda p: remove_model(str(p.get("alias", "")), str(p.get("reason", ""))),
4440
4482
  "/api/restore": lambda p: restore_model(str(p.get("alias", ""))),
4441
4483
  "/api/retry": lambda p: retry_model(str(p.get("alias") or "")),
4484
+ "/api/discover/refresh": lambda p: discover_refresh(),
4442
4485
  "/api/discover/add": lambda p: discover_add(str(p.get("connection") or ""),
4443
4486
  str(p.get("model") or ""),
4444
4487
  endpoint=str(p.get("endpoint") or ""),
@@ -4471,7 +4514,9 @@ class Handler(gateway.Handler):
4471
4514
  str(p.get("shape") or ""),
4472
4515
  str(p.get("description") or ""),
4473
4516
  p.get("tags"), str(p.get("reply") or ""),
4474
- str(p.get("merger") or "")),
4517
+ str(p.get("merger") or ""), p.get("ad_hoc") is True,
4518
+ max_calls=p.get("max_calls"),
4519
+ max_explorers=p.get("max_explorers")),
4475
4520
  "/api/collection/remove": lambda p: delete_collection(str(p.get("name") or "")),
4476
4521
  "/api/model-facts": lambda p: save_model_facts(str(p.get("alias") or ""), p.get("facts")),
4477
4522
  "/api/model-options": lambda p: save_model_options(str(p.get("alias") or ""), p.get("max_tokens")),
package/ui/bench.js CHANGED
@@ -766,7 +766,8 @@
766
766
  var dd = $('discover-dialog');
767
767
  function price(x) {
768
768
  var c = x.cost || {};
769
- return x.free ? 'free' : (c.input != null ? '$' + c.input + ' in / $' + c.output + ' out per 1M tokens' : 'paid');
769
+ return x.free ? 'free' : x.free == null ? 'price unknown'
770
+ : (c.input != null ? '$' + c.input + ' in / $' + c.output + ' out per 1M tokens' : 'paid');
770
771
  }
771
772
  function limits(l) {
772
773
  return Object.keys(l || {}).map(function (k) { return Number(l[k]).toLocaleString() + ' ' + k; }).join(' · ');
@@ -835,13 +836,15 @@
835
836
  if (res.errors.length) listErrors($('d-errors'), res.errors); // a hub that could not be asked
836
837
  var r = res.model, p = $('d-profile'), list = $('d-routes');
837
838
  $('d-title').textContent = r.name;
838
- // Each source a link: FreeLLMAPI's page for the model where its sitemap has one, else its catalog.
839
+ // Each source a link to its page for the model: FreeLLMAPI's where its sitemap has one, models.dev's by its
840
+ // canonical id. The provider's own list has no page, only its connection.
839
841
  var sv = $('d-served');
840
842
  sv.textContent = r.provider_name + ' · ' + r.model + ' · listed by ';
841
843
  r.sources.forEach(function (s, i) {
842
- var fl = s === 'freellmapi';
843
844
  if (i) sv.appendChild(document.createTextNode(' and '));
844
- sv.appendChild(el('a', {href: fl ? r.page || 'https://freellmapi.co/models' : 'https://models.dev/',
845
+ if (s === 'provider') { sv.appendChild(document.createTextNode(r.provider_name + '\'s own list (' + r.connection + ' connection)')); return; }
846
+ var fl = s === 'freellmapi';
847
+ sv.appendChild(el('a', {href: fl ? r.page || 'https://freellmapi.co/models' : r.md_page || 'https://models.dev/',
845
848
  target: '_blank', rel: 'noopener'}, fl ? 'FreeLLMAPI' : 'models.dev'));
846
849
  });
847
850
  if (r.on_bench_as) sv.appendChild(document.createTextNode(' · on the bench as ' + r.on_bench_as));
@@ -905,6 +908,16 @@
905
908
  e.preventDefault(); openDiscover(row.dataset.provider, row.dataset.model);
906
909
  });
907
910
  $('d-close').addEventListener('click', function () { dd.close(); });
911
+ // Refresh lists: every list fetched now (serve.discover_refresh), then the page with them.
912
+ var dr = $('d-refresh');
913
+ if (dr) dr.addEventListener('click', function () {
914
+ var said = $('d-refreshed');
915
+ dr.disabled = true; dr.textContent = 'Fetching…'; said.textContent = 'about half a minute';
916
+ post('/api/discover/refresh', {}).then(function (res) {
917
+ said.textContent = (res.errors.length ? res.errors.join(' · ') + ' · ' : '') + 'Reloading…';
918
+ setTimeout(function () { location.reload(); }, res.errors.length ? 4000 : 500);
919
+ }).catch(function (err) { said.textContent = String(err); dr.disabled = false; dr.textContent = 'Refresh lists'; });
920
+ });
908
921
  function follow(log, title, before) {
909
922
  runView(title, before + 'Running in the background; closing this does not stop it. Log: runs/' + log);
910
923
  timer = setInterval(function () {
@@ -1331,10 +1344,14 @@
1331
1344
  head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
1332
1345
  gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
1333
1346
  del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
1334
- seats: $('c-seats'), prov: $('c-member-provider')};
1347
+ seats: $('c-seats'), prov: $('c-member-provider'), adHoc: $('c-ad-hoc'),
1348
+ ownCalls: $('c-max-calls'), ownExp: $('c-max-explorers')};
1335
1349
  var C = null, open = null; // the collection whose details are showing
1350
+ // What the dialog's two limits come to: empty follows the gateway settings.
1351
+ function limit(input, fallback) { return input.value === '' ? fallback : parseInt(input.value, 10); }
1352
+ function maxCalls() { return limit(c.ownCalls, C.settings.max_calls); }
1336
1353
  function explorersFor(n) {
1337
- return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
1354
+ return Math.max(0, Math.min(limit(c.ownExp, C.settings.max_explorers), maxCalls() - n));
1338
1355
  }
1339
1356
  function picked() {
1340
1357
  return Array.prototype.filter.call(c.members.querySelectorAll('input'), function (x) { return x.checked; })
@@ -1356,7 +1373,7 @@
1356
1373
  return ' · ' + t.name + ' is ticked on ' + t.on.join(' and ') + ': one model agrees with itself, it does not check itself';
1357
1374
  }).join('');
1358
1375
  c.members.querySelectorAll('input').forEach(function (x) {
1359
- x.disabled = !x.checked && (n >= C.settings.max_calls || !!x.dataset.blocked);
1376
+ x.disabled = !x.checked && (n >= maxCalls() || !!x.dataset.blocked);
1360
1377
  });
1361
1378
  drawSeats(ex);
1362
1379
  }
@@ -1435,9 +1452,9 @@
1435
1452
  }
1436
1453
  // The member search: the filter boxes' match (any text, case-insensitive),
1437
1454
  // over the model's name and each chip's alias, served name and provider,
1438
- // and the provider select beside it. It only hides chips, and never a ticked
1439
- // one, so what is picked is always in view and every count still includes
1440
- // it; a row goes when all its chips do.
1455
+ // and the provider select beside it. It only hides chips, ticked ones too:
1456
+ // the seats above the list always show what is picked, each with its ×, and
1457
+ // every count still includes it. A row goes when all its chips do.
1441
1458
  function filterMembers() {
1442
1459
  var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
1443
1460
  c.members.querySelectorAll('.m-row').forEach(function (row) {
@@ -1446,8 +1463,8 @@
1446
1463
  var box = lab.querySelector('input');
1447
1464
  var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
1448
1465
  lab.textContent).toLowerCase();
1449
- lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1
1450
- || !!prov && (fact(box.value).provider || '') !== prov);
1466
+ lab.hidden = !box.checked && !!lab.dataset.paid || !named && text.indexOf(q) === -1
1467
+ || !!prov && (fact(box.value).provider || '') !== prov;
1451
1468
  any = any || !lab.hidden;
1452
1469
  });
1453
1470
  row.hidden = !any;
@@ -1483,6 +1500,8 @@
1483
1500
  function showMerger() { $('c-merger-row').hidden = c.reply.value !== 'merged'; }
1484
1501
  c.reply.addEventListener('change', showMerger);
1485
1502
  c.shape.addEventListener('change', recount); // the explorer seats say their shape
1503
+ c.ownCalls.addEventListener('input', recount);
1504
+ c.ownExp.addEventListener('input', recount);
1486
1505
  // A collection opens in a popup, as every other edit on the dashboard does:
1487
1506
  // the one clicked, or a new one from New collection.
1488
1507
  function closeForm() {
@@ -1497,6 +1516,10 @@
1497
1516
  if (!c.form.open) c.form.showModal();
1498
1517
  c.name.value = row ? row.name : ''; c.name.readOnly = !!row;
1499
1518
  c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
1519
+ c.adHoc.checked = !!row && row.ad_hoc;
1520
+ c.ownCalls.value = row && row.max_calls !== undefined ? row.max_calls : '';
1521
+ c.ownExp.value = row && row.max_explorers !== undefined ? row.max_explorers : '';
1522
+ c.ownCalls.placeholder = C.settings.max_calls; c.ownExp.placeholder = C.settings.max_explorers;
1500
1523
  drawTagPicks();
1501
1524
  c.members.textContent = ''; c.find.value = '';
1502
1525
  c.prov.length = 1; // "Every provider", then each one a model is on
@@ -1565,6 +1588,7 @@
1565
1588
  var left = el('div');
1566
1589
  var title = el('div', {class: 'top'});
1567
1590
  title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name));
1591
+ if (row.ad_hoc) title.appendChild(el('span', {class: 'chip'}, 'ad hoc: only when called by name'));
1568
1592
  row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip'}, t)); });
1569
1593
  left.appendChild(title);
1570
1594
  if (row.description) left.appendChild(el('p', {class: 'desc'}, row.description));
@@ -1631,7 +1655,9 @@
1631
1655
  var name = c.name.value.trim().replace(/^bench\//, '');
1632
1656
  post('/api/collection', {name: name, members: picked(), shape: c.shape.value, description: c.desc.value,
1633
1657
  tags: c.tags.value.split(','), reply: c.reply.value,
1634
- merger: c.merger.value}).then(function (r) {
1658
+ merger: c.merger.value, ad_hoc: c.adHoc.checked,
1659
+ max_calls: c.ownCalls.value === '' ? null : Number(c.ownCalls.value),
1660
+ max_explorers: c.ownExp.value === '' ? null : Number(c.ownExp.value)}).then(function (r) {
1635
1661
  if (!r.ok) { listErrors(c.errors, r.errors); return; }
1636
1662
  finished('Saved bench/' + name + '.');
1637
1663
  }).catch(function (err) { c.errors.textContent = String(err); });