@wendellresearch-ai/benchwarmers 0.2.1 → 0.2.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/serve.py CHANGED
@@ -683,7 +683,7 @@ def retry_model(alias: str, who: str = "ui") -> dict:
683
683
  with (DATA / "perf" / "history.csv").open("a", encoding="utf-8", newline="") as fh:
684
684
  csv.writer(fh).writerow([datetime.datetime.now().isoformat(timespec="seconds"), alias, "retry", 1,
685
685
  f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}",
686
- 0.0, 1, 0, 0, 0, "", "retry from the dashboard"])
686
+ 0.0, 1, 0, 0, 0, "", "retry from the dashboard", ""])
687
687
  import alerts as _alerts
688
688
  served = {a: s for s, a in names.items()}.get(alias, "")
689
689
  # Exactly this model's: auto_retire.py raises model:pending:<served> with its alias.
@@ -1244,10 +1244,11 @@ def connect_page(out: dict) -> str:
1244
1244
  f"<p><a href='{html.escape(home)}'>Back to Benchwarmers</a>. You can close this tab.</p>")
1245
1245
 
1246
1246
 
1247
- def _clean_limits(raw) -> tuple[dict, list[str]]:
1248
- """rpm/rpd/tpm/tpd as positive whole numbers; blank clears one."""
1247
+ def _clean_limits(raw, keys=catalog.LIMIT_KEYS) -> tuple[dict, list[str]]:
1248
+ """rpm/rpd/tpm/tpd (and a connection's rpmo) as positive whole numbers;
1249
+ blank clears one."""
1249
1250
  out, errors = {}, []
1250
- for k in catalog.LIMIT_KEYS:
1251
+ for k in keys:
1251
1252
  v = (raw or {}).get(k)
1252
1253
  if v in (None, ""):
1253
1254
  continue
@@ -1355,7 +1356,7 @@ def save_connection_limits(connection: str, limits: dict, who: str = "ui") -> di
1355
1356
  if connection not in known:
1356
1357
  return {"ok": False, "errors": [f"{connection!r} is not a connection any model uses;"
1357
1358
  f" known: {', '.join(sorted(known))}"]}
1358
- clean, errors = _clean_limits(limits)
1359
+ clean, errors = _clean_limits(limits, catalog.CONNECTION_LIMIT_KEYS)
1359
1360
  if errors:
1360
1361
  return {"ok": False, "errors": errors}
1361
1362
  data = catalog._read(catalog.LIMITS)
@@ -1376,7 +1377,8 @@ def connections_view() -> dict:
1376
1377
  for a in _chat_aliases():
1377
1378
  by.setdefault(catalog.connection_of(a), []).append(a)
1378
1379
  lim = catalog.connection_limits()
1379
- return {"ok": True, "errors": [], "keys": list(catalog.LIMIT_KEYS),
1380
+ return {"ok": True, "errors": [], "keys": list(catalog.CONNECTION_LIMIT_KEYS),
1381
+ "labels": {k: catalog.LIMIT_LABELS[k] for k in catalog.CONNECTION_LIMIT_KEYS},
1380
1382
  "connections": [{"name": c, "models": sorted(m), "limits": lim.get(c, {})}
1381
1383
  for c, m in sorted(by.items())]}
1382
1384
 
@@ -1511,7 +1513,15 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
1511
1513
  return {"ok": True, "errors": [], "changed": ["config/gateway.json"]}
1512
1514
 
1513
1515
 
1514
- def start_onboard(served: str, endpoint: str = "", who: str = "ui") -> dict:
1516
+ # How much an onboarding spends. Full: the 4-call probe, then the suite now.
1517
+ # Quick: the probe only; the scheduled `suite --next` passes fill the suite in,
1518
+ # six tasks a run, so the same calls are spread out, not saved. Quick + ad
1519
+ # hoc: the probe only, and the bench never calls it on its own: the only one
1520
+ # that saves calls, for a metered, capped or trial key.
1521
+ ONBOARD_MODES = {"full": [], "quick": ["--skip-suite"], "quick-adhoc": ["--skip-suite", "--ad-hoc"]}
1522
+
1523
+
1524
+ def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str = "full") -> dict:
1515
1525
  """Kick off `scripts/onboard.py` for a served model, detached, logging to runs/.
1516
1526
 
1517
1527
  The script does its own checks in the right order (is it served, derive
@@ -1521,12 +1531,12 @@ def start_onboard(served: str, endpoint: str = "", who: str = "ui") -> dict:
1521
1531
  hands the rest to the script. It runs for minutes and spends calls, so it
1522
1532
  runs in the background and the page tails its log.
1523
1533
  """
1524
- return _spawn_onboard(served, endpoint, who)[0]
1534
+ return _spawn_onboard(served, endpoint, who, mode)[0]
1525
1535
 
1526
1536
 
1527
- def _onboard_refusal(served: str, endpoint: str) -> list[str]:
1537
+ def _onboard_refusal(served: str, endpoint: str, mode: str = "full") -> list[str]:
1528
1538
  """What start_onboard refuses without a call (its docstring)."""
1529
- errors = []
1539
+ errors = [] if mode in ONBOARD_MODES else [f"mode must be one of {', '.join(ONBOARD_MODES)}; got {mode!r}"]
1530
1540
  # It goes into a shell command line below, so only the characters model
1531
1541
  # ids actually use -- not merely "no spaces".
1532
1542
  if not re.fullmatch(r"[\w./:@+-]{1,200}", served):
@@ -1549,18 +1559,20 @@ def _onboard_refusal(served: str, endpoint: str) -> list[str]:
1549
1559
  return errors
1550
1560
 
1551
1561
 
1552
- def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui") -> tuple[dict, subprocess.Popen | None]:
1562
+ def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui",
1563
+ mode: str = "full") -> tuple[dict, subprocess.Popen | None]:
1553
1564
  """start_onboard's result, and the process (None when refused) for a caller that waits on it."""
1554
1565
  served = (served or "").strip()
1555
1566
  endpoint = (endpoint or "").strip() or endpoints.default()
1556
- errors = _onboard_refusal(served, endpoint)
1567
+ errors = _onboard_refusal(served, endpoint, mode)
1557
1568
  if errors:
1558
1569
  return {"ok": False, "errors": errors}, None
1559
1570
  stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
1560
1571
  safe = re.sub(r"[^A-Za-z0-9._-]+", "-", served)
1561
1572
  log = DATA / "runs" / f"onboard-{stamp}-{safe}.log"
1562
1573
  from run import bench_python
1563
- cmd = [bench_python(), str(ROOT / "scripts" / "onboard.py"), served, "--endpoint", endpoint]
1574
+ cmd = [bench_python(), str(ROOT / "scripts" / "onboard.py"), served, "--endpoint", endpoint,
1575
+ *ONBOARD_MODES[mode]]
1564
1576
  # The log handle goes straight to the child, unbuffered, so the page sees
1565
1577
  # each step as it prints. The old `cmd /c ... > log & echo` wrapper with
1566
1578
  # DETACHED_PROCESS sent the output to a console window instead, so every
@@ -1583,7 +1595,7 @@ def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui") -> tuple[di
1583
1595
 
1584
1596
  threading.Thread(target=sentinel, daemon=True).start()
1585
1597
  _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
1586
- who, "", "onboard", None, served)
1598
+ who, "", "onboard" + ("" if mode == "full" else f" ({mode})"), None, served)
1587
1599
  return {"ok": True, "errors": [], "log": log.name}, proc
1588
1600
 
1589
1601
 
@@ -1627,20 +1639,21 @@ def queue_onboard(items, who: str = "ui") -> dict:
1627
1639
  it = it if isinstance(it, dict) else {}
1628
1640
  served = str(it.get("served") or "").strip()
1629
1641
  endpoint = str(it.get("endpoint") or "").strip() or endpoints.default()
1630
- errors += _onboard_refusal(served, endpoint)
1631
- picked.append((served, endpoint))
1642
+ mode = str(it.get("mode") or "full")
1643
+ errors += _onboard_refusal(served, endpoint, mode)
1644
+ picked.append((served, endpoint, mode))
1632
1645
  with _OQ_LOCK:
1633
1646
  waiting = {(i["served"], i["endpoint"]) for i in _OQ["items"] if i["state"] in ("queued", "running")}
1634
- errors += [f"{s} from {e} is already in the queue" for s, e in picked if (s, e) in waiting]
1635
- if len(set(picked)) < len(picked):
1647
+ errors += [f"{s} from {e} is already in the queue" for s, e, _ in picked if (s, e) in waiting]
1648
+ if len({(s, e) for s, e, _ in picked}) < len(picked):
1636
1649
  errors.append("a model is picked twice")
1637
1650
  if errors:
1638
1651
  return {"ok": False, "errors": errors}
1639
1652
  if _OQ["worker"] is None:
1640
1653
  _OQ["items"] = []
1641
1654
  _OQ["stop"] = False # added after a Stop: these run
1642
- _OQ["items"] += [{"served": s, "endpoint": e, "state": "queued", "alias": None, "log": None,
1643
- "exit": None, "note": ""} for s, e in picked]
1655
+ _OQ["items"] += [{"served": s, "endpoint": e, "mode": m, "state": "queued", "alias": None, "log": None,
1656
+ "exit": None, "note": ""} for s, e, m in picked]
1644
1657
  if _OQ["worker"] is None:
1645
1658
  _OQ["worker"] = threading.Thread(target=_onboard_worker, args=(who,), daemon=True)
1646
1659
  _OQ["worker"].start()
@@ -1692,7 +1705,7 @@ def _onboard_worker(who: str) -> None:
1692
1705
  return
1693
1706
  nxt["state"] = "running"
1694
1707
  try:
1695
- res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who)
1708
+ res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who, nxt.get("mode", "full"))
1696
1709
  if proc is None: # refused now: the bench changed since it was queued
1697
1710
  state, note, code = "failed", "; ".join(res["errors"]), None
1698
1711
  else:
@@ -2398,7 +2411,7 @@ def perf_card() -> str:
2398
2411
  if r["model"] in live}
2399
2412
  widest = max((float(r["p50_s"]) for r in latest.values()), default=1) or 1
2400
2413
  body = ["<table><thead><tr>" + th("Model") + th("Prompt") + th("p50", True)
2401
- + th("p95", True) + th("ch/s", True) + th("Outcomes")
2414
+ + th("p95", True) + th("ch/s", True) + th("tok/s", True) + th("Outcomes")
2402
2415
  + "<th>p50, relative</th></tr></thead><tbody>"]
2403
2416
  for (model, prompt), row in sorted(latest.items()):
2404
2417
  p50 = float(row["p50_s"])
@@ -2411,6 +2424,7 @@ def perf_card() -> str:
2411
2424
  f"<td class='num'>{p50:.1f}s</td>"
2412
2425
  f"<td class='num'>{float(row['p95_s']):.1f}s</td>"
2413
2426
  f"<td class='num'>{esc(row['chars_per_s'])}</td>"
2427
+ f"<td class='num'>{esc(row.get('tokens_per_s') or '—')}</td>"
2414
2428
  f"<td class='{'err' if bad else ''}'>{esc(oc)}</td>"
2415
2429
  f"<td><span class='meter' style='width:{width}px' "
2416
2430
  f"title='p50 {p50:.1f}s'><span style='width:100%'></span></span></td></tr>")
@@ -2428,6 +2442,9 @@ def perf_card() -> str:
2428
2442
  questions.</dd>
2429
2443
  <dt>ch/s</dt><dd>Characters per second, from the large prompt only. A
2430
2444
  proxy for tokens/s, affected by how verbose a model is.</dd>
2445
+ <dt>tok/s</dt><dd>Output tokens per second from the provider's own count, first token to
2446
+ last, large prompt only. Only where the built-in client made the call and the provider
2447
+ reported usage; blank otherwise, never estimated.</dd>
2431
2448
  <dt>Outcomes</dt><dd>answered / refused / error / timeout.</dd>
2432
2449
  <dt>Weight it LEAST</dt><dd>Seconds are a wash. Speed only vetoes a model
2433
2450
  when it is slow enough to change how you work.</dd>
@@ -2549,8 +2566,9 @@ def gateway_card(limit: int = 30) -> str:
2549
2566
  explorers, then the merger if the reply was merged. Green answered, amber came back empty, red failed. ✓ judged
2550
2567
  useful, ✗ judged not useful; no mark means the verdict is still owed.
2551
2568
  Hover a chip for its seat, timing and call id; click it to read the answer
2552
- and record a verdict. A grey chip with a dash is a member not asked because the
2553
- request carried images it cannot take; hover it for why.</dd>
2569
+ and record a verdict. A grey chip with a dash is a member not asked: the request
2570
+ carried images it cannot take, or it is a vision specialist and the request
2571
+ carried none. Hover it for why.</dd>
2554
2572
  <dt>Asked by</dt><dd>The gateway key that made the request, or <code>local</code>
2555
2573
  when no key is asked for.</dd>
2556
2574
  <dt>Owed</dt><dd>Seats without a verdict. Each is recorded as an unjudged
@@ -3219,7 +3237,9 @@ def endpoints_page() -> str:
3219
3237
  "<div class='panel form-panel'><h2>Rate limits per connection</h2>"
3220
3238
  "<p class='sub'>What a key behind each connection allows, from the provider's docs or"
3221
3239
  " freellmapi.co/models. Models on that connection share it; a model's own limits (its"
3222
- " popup) win, and limits the bench learns from errors only ever lower these.</p>"
3240
+ " popup) win, and limits the bench learns from errors only ever lower these. rpmo is calls"
3241
+ " a calendar month (UTC), for a trial key's monthly cap. The built-in client keeps to them:"
3242
+ " when one is used up it waits if the call can still finish in time, else it does not send.</p>"
3223
3243
  "<div id='l-list'></div><div class='errors' id='l-errors' role='alert'></div>"
3224
3244
  "<div class='done' id='l-done'></div></div>")
3225
3245
 
@@ -3270,11 +3290,37 @@ def models_page() -> str:
3270
3290
 
3271
3291
 
3272
3292
  def quotas_page() -> str:
3273
- """What limits each connection, and who is resting after one. The live
3274
- numbers (what is left, when it resets) come with the built-in client's
3275
- quota records, RELEASE-PLAN Phase 2b."""
3293
+ """What limits each connection, who is resting after one, what providers
3294
+ last said was left (the built-in client's quota records, metrics/quota.py)
3295
+ and what the bench itself sent (its usage ledger, metrics/ledger.py)."""
3276
3296
  import learn
3297
+ import ledger
3298
+ import quota
3277
3299
  now = datetime.datetime.now().astimezone()
3300
+ rec_rows = []
3301
+ for r in sorted(quota.live(), key=lambda r: (r.get("connection", ""), r.get("alias", ""), r.get("metric", ""))):
3302
+ try:
3303
+ age = int((now - datetime.datetime.fromisoformat(r["observed_at"])).total_seconds() // 60)
3304
+ reset = datetime.datetime.fromisoformat(r["reset_at"]).astimezone().strftime("%Y-%m-%d %H:%M")
3305
+ except (KeyError, TypeError, ValueError):
3306
+ continue
3307
+ num = lambda v: f"{v:,}" if isinstance(v, int) else "—" # noqa: E731
3308
+ rec_rows.append(f"<tr><td>{esc(r.get('connection', ''))}</td><td>{named(r.get('alias', ''))}</td>"
3309
+ f"<td>{esc(r.get('metric', ''))} / {esc(r.get('window', ''))}</td>"
3310
+ f"<td class='num'>{num(r.get('remaining'))}</td><td class='num'>{num(r.get('limit'))}</td>"
3311
+ f"<td class='mono'>{esc(reset)}</td>"
3312
+ f"<td>{esc(r.get('source', ''))}, {age} min ago</td></tr>")
3313
+ t = now.timestamp()
3314
+ sent: dict = {}
3315
+ for row in ledger.rows(min(t - 86400, ledger.month_start(t))):
3316
+ s = sent.setdefault(row.get("connection") or "?", [0, 0, 0, 0])
3317
+ n, tok = row.get("attempts") or 0, (row.get("tokens_in") or 0) + (row.get("tokens_out") or 0)
3318
+ if row["t"] >= t - 86400:
3319
+ s[0], s[1] = s[0] + n, s[1] + tok
3320
+ if row["t"] >= ledger.month_start(t):
3321
+ s[2], s[3] = s[2] + n, s[3] + tok
3322
+ sent_rows = [f"<tr><td>{esc(c)}</td>" + "".join(f"<td class='num'>{v:,}</td>" for v in s) + "</tr>"
3323
+ for c, s in sorted(sent.items())]
3278
3324
  cool_rows = []
3279
3325
  for alias, c in sorted(learn.cooldowns().items(), key=lambda kv: kv[1].get("until", "")):
3280
3326
  try:
@@ -3285,7 +3331,7 @@ def quotas_page() -> str:
3285
3331
  f"<td class='num' data-sort='{left}'>{left} min</td>"
3286
3332
  f"<td>{'stated by the provider' if c.get('stated') else 'default wait'}</td></tr>")
3287
3333
  entered = catalog.connection_limits()
3288
- keys = catalog.LIMIT_KEYS
3334
+ keys = catalog.CONNECTION_LIMIT_KEYS
3289
3335
  lim_rows = [f"<tr><td>{esc(conn)}</td>" + "".join(
3290
3336
  f"<td class='num'>{lim[k]:,}</td>" if lim.get(k) else "<td class='num muted'>—</td>" for k in keys)
3291
3337
  + "<td>you</td></tr>" for conn, lim in sorted(entered.items()) if any(lim.get(k) for k in keys)]
@@ -3300,10 +3346,23 @@ def quotas_page() -> str:
3300
3346
  + f"<td>learned {esc(ev.get('at', '')[:10])} ({esc(ev.get('where', ''))})</td></tr>")
3301
3347
  return (
3302
3348
  "<div class='panel'><h2>Quota management</h2><p class='sub'>What is left on each connection and"
3303
- " when it resets, read from provider endpoints, rate-limit headers and the bench's own call"
3304
- " ledger, arrives with the built-in client's quota records (RELEASE-PLAN Phase 2b). Until then:"
3305
- " who is resting after a rate limit, and every limit known. Quotas of OAuth credentials stay"
3306
- " in CLI Proxy API's own console.</p></div>"
3349
+ " when it resets, from the rate-limit headers and 429 replies the built-in client sees, and what"
3350
+ " the bench sent itself, from its usage ledger; who is resting after a rate limit, and every"
3351
+ " limit known. The built-in client keeps to all of these. Calls through the llm CLI are in"
3352
+ " neither record. Quotas of OAuth credentials stay in CLI Proxy API's own console.</p></div>"
3353
+ "<div class='panel'><h3 class='chart-head'>What providers said is left · " + str(len(rec_rows)) + "</h3>"
3354
+ + ("<table><thead><tr>" + th("Connection") + th("Model") + th("Counts") + th("Left", True)
3355
+ + th("Of", True) + th("Resets") + th("From") + f"</tr></thead><tbody>{''.join(rec_rows)}</tbody></table>"
3356
+ if rec_rows else "<p class='muted'>Nothing in force: no provider has stated a limit whose reset"
3357
+ " is still ahead.</p>")
3358
+ + "</div>"
3359
+ "<div class='panel'><h3 class='chart-head'>What the bench sent</h3><p class='sub'>Requests"
3360
+ " (retries included) and tokens per connection, from the usage ledger.</p>"
3361
+ + ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
3362
+ + th("Requests, this month", True) + th("Tokens, this month", True)
3363
+ + f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
3364
+ if sent_rows else "<p class='muted'>No call in the ledger this month.</p>")
3365
+ + "</div>"
3307
3366
  "<div class='panel'><h3 class='chart-head'>Resting after a rate limit · " + str(len(cool_rows)) + "</h3>"
3308
3367
  + ("<table><thead><tr>" + th("Model") + th("Connection") + th("Back in", True) + th("Wait")
3309
3368
  + f"</tr></thead><tbody>{''.join(cool_rows)}</tbody></table>" if cool_rows
@@ -3476,8 +3535,10 @@ def editor() -> str:
3476
3535
  "<div class='actions'><button class='btn' id='co-save' type='button'>Save options</button></div>"
3477
3536
  "</details>"
3478
3537
  "<label class='check'><input type='checkbox' id='m-adhoc'> Ad hoc only</label>"
3479
- "<p class='rules'>For expensive or capped models: never seated automatically, never"
3480
- " swept, never probed daily. Used only when named.</p>"
3538
+ "<p class='rules'>Never chosen by the bench on its own: no explorer seat, no router"
3539
+ " pick, no scheduled suite, perf or availability runs. Still called whenever you choose"
3540
+ " it: as a collection member (or merger), with <code>with</code>, or with"
3541
+ " <code>--model</code>. For metered, capped or trial keys.</p>"
3481
3542
  "</div>"
3482
3543
  "<div class='errors' id='m-errors' role='alert'></div><div class='done' id='m-done'></div>"
3483
3544
  "<div class='actions'><button class='btn' id='m-close'>Close</button>"
@@ -3509,6 +3570,12 @@ def editor() -> str:
3509
3570
  "<input type='search' id='o-find' placeholder='Search models or connections'"
3510
3571
  " aria-label='Search models'>"
3511
3572
  "<div class='picker' id='o-picker' role='group' aria-label='Served models'></div>"
3573
+ # serve.ONBOARD_MODES: how much each onboarding spends.
3574
+ "<label class='check'>Onboard as <select id='o-mode'>"
3575
+ "<option value='full'>Full: probe, then the suite now</option>"
3576
+ "<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
3577
+ "<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
3578
+ " (metered, capped or trial keys)</option></select></label>"
3512
3579
  "</div>"
3513
3580
  "<div id='o-run' hidden><p class='served' id='o-result'></p><ol class='steps' id='o-steps'></ol>"
3514
3581
  "<details><summary class='muted'>Full log</summary><pre class='log' id='o-log'></pre></details></div>"
@@ -3755,7 +3822,8 @@ class Handler(gateway.Handler):
3755
3822
  str(p.get("model") or ""),
3756
3823
  endpoint=str(p.get("endpoint") or "")),
3757
3824
  "/api/onboard": lambda p: start_onboard(str(p.get("served", "")),
3758
- str(p.get("endpoint", ""))),
3825
+ str(p.get("endpoint", "")),
3826
+ mode=str(p.get("mode") or "full")),
3759
3827
  "/api/onboard-queue": lambda p: queue_onboard(p.get("items")),
3760
3828
  "/api/onboard-queue/stop": lambda p: stop_onboard_queue(),
3761
3829
  "/api/onboard-queue/clear": lambda p: clear_onboard_queue(),
package/ui/bench.js CHANGED
@@ -573,7 +573,8 @@
573
573
  // model at a time. The popup closes: the header's chip shows the batch, and
574
574
  // its Details the queue. Refused, it stays open with the reasons.
575
575
  o.start.addEventListener('click', function () {
576
- var items = Object.keys(chosen).map(function (k) { return chosen[k]; });
576
+ var mode = $('o-mode').value;
577
+ var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode}, chosen[k]); });
577
578
  if (!items.length) return;
578
579
  o.errors.textContent = ''; o.start.disabled = o.dismiss.disabled = true; o.start.textContent = 'Starting…';
579
580
  post('/api/onboard-queue', {items: items}).then(function (res) {
@@ -1016,7 +1017,7 @@
1016
1017
  left.appendChild(el('div', {class: 'base'}, c.models.join(', ')));
1017
1018
  var inputs = el('div', {class: 'limits'});
1018
1019
  res.keys.forEach(function (k) {
1019
- var lab = el('label', {}, k + ' ');
1020
+ var lab = el('label', {title: (res.labels || {})[k] || k}, k + ' ');
1020
1021
  var box = el('input', {type: 'number', min: '1', 'data-k': k, placeholder: '—'});
1021
1022
  if (c.limits[k]) box.value = c.limits[k];
1022
1023
  lab.appendChild(box); inputs.appendChild(lab);