@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/serve.py CHANGED
@@ -221,6 +221,17 @@ def tag_for(llm_id: str) -> str:
221
221
  return "" if tag == "?" else tag
222
222
 
223
223
 
224
+ def provider_label(tag: str) -> str:
225
+ """A connection tag as a short provider name for a chip: a proxy's vendor
226
+ (cli-proxy-api/codex: codex), else the host without its api./
227
+ router. prefix, -api suffix and TLD (router.requesty.ai: requesty)."""
228
+ if "/" in tag:
229
+ return tag.split("/", 1)[1]
230
+ host = re.sub(r"^(api|apihub|router)\.", "", tag)
231
+ host = host.rsplit(".", 1)[0] if "." in host else host
232
+ return host.removesuffix("-api") or tag
233
+
234
+
224
235
  def retired() -> dict[str, dict]:
225
236
  """Aliases removed from the bench by hand, with when and why."""
226
237
  return load_json(DATA / "config" / "retired.json")
@@ -826,15 +837,16 @@ def _modalities(entry: dict, served: str, index: dict) -> dict:
826
837
  # What each endpoint answered, for LISTED_FOR seconds: name -> (when, its
827
838
  # config, live, conns). Only the asking is kept; what is on the bench, retired
828
839
  # or dismissed is taken off fresh on every opening.
829
- LISTED_FOR = 300
840
+ LISTED_FOR = 3600 # an hour: lists rarely change sooner, and Refresh is one click
830
841
  _listed: dict[str, tuple] = {}
831
842
 
832
843
 
833
844
  def onboard_candidates(fresh: bool = False) -> dict:
834
845
  """What every endpoint serves that the bench could onboard, for the picker.
835
846
 
836
- Served, minus registered, minus retired, minus dismissed (those come back
837
- separately so a dismissal can be undone), each endpoint asked separately
847
+ Served, minus registered, minus retired, minus queued or onboarding now,
848
+ minus dismissed (those come back separately so a dismissal can be undone),
849
+ each endpoint asked separately
838
850
  so one being down only empties its own group. The live calls this page
839
851
  makes happen only when the picker opens: a dashboard that needs the
840
852
  network to render would be down exactly when you want to look at it.
@@ -858,6 +870,9 @@ def onboard_candidates(fresh: bool = False) -> dict:
858
870
  | {endpoints.snapshot_key(r.get("served", ""), r.get("endpoint") or endpoints.default())
859
871
  for r in retired().values()}
860
872
  | {endpoints.snapshot_key(d["served"], d["endpoint"]) for d in decide.load().values()})
873
+ with _OQ_LOCK: # on its way: Start would refuse it anyway
874
+ taken |= {endpoints.snapshot_key(i["served"], i["endpoint"]) for i in _OQ["items"]
875
+ if i["state"] in ("queued", "running")}
861
876
  tags = load_json(DATA / "config" / "provider-tags.json")
862
877
  dismissed = load_json(DISMISSED_PATH)
863
878
  first_seen = load_json(DATA / "config" / "served-snapshot.json").get("last_seen", {})
@@ -1457,12 +1472,18 @@ def collections_view() -> dict:
1457
1472
  # What the tags check members against (bench.js tagBlocks): capabilities
1458
1473
  # (None: the catalog does not say), context, price and role. A shape's
1459
1474
  # tag admits the roles that shape admits, as route.py does for seats.
1475
+ # The member picker draws one row per model, its providers as chips:
1476
+ # `family` is route.family_of, the match that keeps one model out of two
1477
+ # seats, and `provider` the chip's name for its connection.
1478
+ import route
1460
1479
  roles = model_roles.load()
1461
1480
  facts = {}
1462
1481
  for a in _chat_aliases():
1463
1482
  f = catalog.info(a)
1483
+ tag = directory[a].get("tag") or directory[a].get("endpoint") or ""
1464
1484
  facts[a] = {"caps": {c: f.get(c) for c in catalog.CAPS}, "context": f.get("context_window"),
1465
- "price": price_of(a, served[a], f), "role": (roles.get("models") or {}).get(a, "general")}
1485
+ "price": price_of(a, served[a], f), "role": (roles.get("models") or {}).get(a, "general"),
1486
+ "family": route.family_of(a), "provider": provider_label(tag), "connection": tag}
1466
1487
  return {"ok": True, "errors": [], "collections": rows, "settings": s,
1467
1488
  "shapes": shapes, "models": _chat_aliases(), "served": served, "tag_suggestions": suggestions,
1468
1489
  "facts": facts, "shape_roles": {k: v for k, v in roles["shapes"].items() if k in shapes},
@@ -1567,13 +1588,29 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
1567
1588
 
1568
1589
  # How much an onboarding spends. Full: the 4-call probe, then the suite now.
1569
1590
  # Quick: the probe only; the scheduled `suite --next` passes fill the suite in,
1570
- # six tasks a run, so the same calls are spread out, not saved. Quick + ad
1571
- # hoc: the probe only, and the bench never calls it on its own: the only one
1572
- # that saves calls, for a metered, capped or trial key. Apart from the mode,
1591
+ # six tasks a run, so the same calls are spread out, not saved. Ad hoc: the
1592
+ # bench never calls it on its own afterwards, for a metered, capped or trial
1593
+ # key; Full + ad hoc spends the suite once now so routing has scores for it,
1594
+ # Quick + ad hoc only the probe, and no scheduled pass ever fills its suite in
1595
+ # (suite/pace.py leaves ad hoc models out). Apart from the mode,
1573
1596
  # `vision` (the picker's Vision model box) gives it role vision as it is
1574
1597
  # registered: a seat only for requests that carry images, and its suite is the
1575
1598
  # vision tasks alone (suite/run.applies), now or through the scheduled passes.
1576
- ONBOARD_MODES = {"full": [], "quick": ["--skip-suite"], "quick-adhoc": ["--skip-suite", "--ad-hoc"]}
1599
+ ONBOARD_MODES = {"full": [], "full-adhoc": ["--ad-hoc"], "quick": ["--skip-suite"],
1600
+ "quick-adhoc": ["--skip-suite", "--ad-hoc"]}
1601
+
1602
+
1603
+ def onboard_choice(prefix: str) -> str:
1604
+ """The mode and the Vision box, as the Onboard dialog (o-) and Discover's Add confirm (ask-) show them."""
1605
+ return (f"<label class='check'>Onboard as <select id='{prefix}-mode'>"
1606
+ "<option value='full'>Full: probe, then the suite now</option>"
1607
+ "<option value='full-adhoc'>Full + ad hoc: probe and the suite now; then the bench never calls it"
1608
+ " on its own</option>"
1609
+ "<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
1610
+ "<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
1611
+ " (metered, capped or trial keys)</option></select></label>"
1612
+ f"<label class='check'><input type='checkbox' id='{prefix}-vision'> Vision model: a seat only for"
1613
+ " questions about images, and its suite is the vision tasks</label>")
1577
1614
 
1578
1615
 
1579
1616
  def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str = "full",
@@ -1780,13 +1817,17 @@ def _onboard_worker(who: str) -> None:
1780
1817
  nxt.update(state=state, note=note, exit=code)
1781
1818
 
1782
1819
 
1783
- def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = "") -> dict:
1820
+ def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = "",
1821
+ mode: str = "full", vision: bool = False) -> dict:
1784
1822
  """From the Discover tab: put a model into a hub as it is otherwise done by
1785
1823
  hand, through the adapter of the hub `endpoint` is (endpoints.adapter:
1786
1824
  Open WebUI's connection Model IDs and a public, tagged record with a logo;
1787
1825
  a LiteLLM deployment; a CLI Proxy API provider's models list), then the
1788
- normal onboarding. Only for a connection the hub already has. No endpoint
1826
+ normal onboarding, in the mode and with the Vision box the confirm chose
1827
+ (ONBOARD_MODES). Only for a connection the hub already has. No endpoint
1789
1828
  means Open WebUI's, as before there were other hubs."""
1829
+ if mode not in ONBOARD_MODES: # before the hub has it, not after
1830
+ return {"ok": False, "errors": [f"mode must be one of {', '.join(ONBOARD_MODES)}; got {mode!r}"]}
1790
1831
  if not re.fullmatch(r"[\w./:@+-]{1,200}", model or ""):
1791
1832
  return {"ok": False, "errors": ["the model id is the provider's id, e.g. vendor/model:free"]}
1792
1833
  if not re.fullmatch(r"[\w.-]{1,80}", connection or ""):
@@ -1805,7 +1846,7 @@ def discover_add(connection: str, model: str, who: str = "ui", endpoint: str = "
1805
1846
  return {"ok": False, "errors": [f"{label} refused: {type(exc).__name__}: {exc}"]}
1806
1847
  _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "",
1807
1848
  f"{which}-add", None, f"{served} on {connection}")
1808
- started = queue_onboard([{"served": served, "endpoint": hub.endpoint()}], who)
1849
+ started = queue_onboard([{"served": served, "endpoint": hub.endpoint(), "mode": mode, "vision": vision}], who)
1809
1850
  return {**started, "served": served, "endpoint": hub.endpoint(), "steps": steps,
1810
1851
  "errors": started["errors"] + ([] if started["ok"] else
1811
1852
  [f"{label} now has {served}; onboard it from the Onboard dialog"])}
@@ -1964,14 +2005,26 @@ def problems() -> str:
1964
2005
  KIND = {"retiring": "err", "retired": "err", "connection": "warn",
1965
2006
  "registration": "warn", "plan": "warn"}
1966
2007
  items = []
2008
+ live = set(names.values())
1967
2009
  for key, a in sorted(alerts.items(), key=lambda kv: kv[1].get("first_seen", "")):
1968
2010
  served = key.split(":", 2)[2] if key.count(":") >= 2 else ""
1969
2011
  alias = a.get("alias") or names.get(served, "")
2012
+ # The alerts file is rewritten by the daily check; until then an alert
2013
+ # whose models have all left the bench (Retire all, or one by one) has
2014
+ # nothing left to act on.
2015
+ if a.get("aliases") and not live & set(a["aliases"]):
2016
+ continue
2017
+ if a.get("kind") == "retiring" and alias and alias not in live:
2018
+ continue
1970
2019
  try:
1971
2020
  age = (today - datetime.date.fromisoformat(a["first_seen"])).days
1972
2021
  except (KeyError, ValueError):
1973
2022
  age = 0
1974
- why = " · ".join(filter(None, [a.get("action", ""),
2023
+ # Which model and where it comes from, first: the message alone left that to be looked up.
2024
+ served_as = next((s for s, al in names.items() if al == alias), served) if alias else ""
2025
+ where = (f"{alias} · {served_as or '?'} via {tag_for(alias) or a.get('endpoint') or '?'}"
2026
+ if alias else "")
2027
+ why = " · ".join(filter(None, [where, a.get("action", ""),
1975
2028
  "open today" if age == 0 else f"open {age} days"]))
1976
2029
  acts = []
1977
2030
  if a.get("aliases"):
@@ -1988,7 +2041,10 @@ def problems() -> str:
1988
2041
  items.append((key, a.get("kind", "?"), KIND.get(a.get("kind"), "warn"),
1989
2042
  a.get("message", ""), why, "".join(acts)))
1990
2043
  cutoff = (today - datetime.timedelta(days=7)).isoformat()
1991
- auto = [(f"retired:{alias}:{r['at']}", "", "", f"{alias} · {r['at'][:10]}",
2044
+ tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
2045
+ auto = [(f"retired:{alias}:{r['at']}", "", "",
2046
+ f"{alias} · {r.get('served') or '?'} via {tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default()}"
2047
+ f" · {r['at'][:10]}",
1992
2048
  r.get("why", "").removeprefix("auto: "),
1993
2049
  f"<button class='btn small' data-act='restore' data-alias='{esc(alias)}'>Restore</button>")
1994
2050
  for alias, r in sorted(retired().items(), key=lambda kv: kv[1].get("at", ""), reverse=True)
@@ -2204,14 +2260,15 @@ def on_bench() -> set[str]:
2204
2260
  return set(registered().values())
2205
2261
 
2206
2262
 
2207
- def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = "") -> str:
2263
+ def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = "",
2264
+ retired: bool = True) -> str:
2208
2265
  """A Benchmarks panel: the chart first (drawn by bench.js with Observable
2209
2266
  Plot), the table folded under it, the key under that."""
2210
2267
  return (f"<div class='panel'><h2>{title}</h2><p class='sub'>{sub}</p>{extra}"
2211
2268
  f"<div class='chart' id='{chart_id}'></div>"
2212
2269
  "<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
2213
2270
  " data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
2214
- "<label><input type='checkbox' data-retired> Show retired</label></div>"
2271
+ + ("<label><input type='checkbox' data-retired> Show retired</label>" if retired else "") + "</div>"
2215
2272
  f"<div class='scroll'>{table}</div></details>"
2216
2273
  f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
2217
2274
 
@@ -2590,6 +2647,8 @@ def suite_card() -> str:
2590
2647
 
2591
2648
 
2592
2649
  EXTERNAL = (("LiveBench", "https://livebench.ai/", "contamination-limited questions, refreshed monthly"),
2650
+ ("FreeLLMAPI", "https://freellmapi.co/models",
2651
+ "every free tier: limits, ranks and quirks per model (Discover leads with it)"),
2593
2652
  ("models.dev", "https://models.dev/", "specs and prices per provider (the catalog reads it daily)"),
2594
2653
  ("BullshitBench", "https://petergpt.github.io/bullshit-benchmark/viewer/index.next.html",
2595
2654
  "pushing back on nonsense; the idea behind confidently wrong"))
@@ -2788,7 +2847,7 @@ def gateway_card(limit: int = 30) -> str:
2788
2847
  judged = {}
2789
2848
  owed = seats_total = 0
2790
2849
  body = ["<table><thead><tr>", th("When"), th("Collection"), th("Asked by"), th("Explorer shape"),
2791
- th("Seats"), th("Owed", True), "</tr></thead><tbody>"]
2850
+ th("Seats"), th("Time", True), "</tr></thead><tbody>"]
2792
2851
  for r in records:
2793
2852
  chips, row_owed = [], 0
2794
2853
  for seat in r.get("seats", []):
@@ -2808,13 +2867,20 @@ def gateway_card(limit: int = 30) -> str:
2808
2867
  chips += [f"<span class='chip muted' title='{esc('not asked: ' + str(k.get('why', '')))}'>"
2809
2868
  f"{esc(k.get('alias', ''))} –</span>" for k in r.get("skipped", []) if isinstance(k, dict)]
2810
2869
  owed += row_owed
2870
+ # The request's own time: its seats ran at once, so the slowest of
2871
+ # them, then the merge (a merger seat answers after the rest).
2872
+ secs = [(s.get("seat"), float(s.get("seconds") or 0)) for s in r.get("seats", [])]
2873
+ panel = max((t for seat, t in secs if seat != "merger"), default=0.0)
2874
+ merge = sum(t for seat, t in secs if seat == "merger")
2875
+ took = (f"<td class='num' data-sort='{panel + merge:.1f}' title='slowest seat {panel:.1f}s"
2876
+ f"{f' + merge {merge:.1f}s' if merge else ''}'>{panel + merge:.0f}s</td>" if secs
2877
+ else "<td class='num muted' data-sort='-1'>—</td>")
2811
2878
  body.append(
2812
2879
  f"<tr><td class='mono' data-sort='{esc(r.get('at', ''))}'>{esc(r.get('at', '')[:16].replace('T', ' '))}</td>"
2813
2880
  f"<td class='mono'>{esc(r.get('model', ''))}</td>"
2814
2881
  f"<td class='mono'>{esc(r.get('key') or '—')}</td>"
2815
2882
  f"<td title='{esc(r.get('shape_by', ''))}'>{esc(r.get('shape', ''))}</td>"
2816
- f"<td>{' '.join(chips)}</td>"
2817
- f"<td class='num{' muted' if not row_owed else ''}'>{row_owed}</td></tr>")
2883
+ f"<td>{' '.join(chips)}</td>{took}</tr>")
2818
2884
  if not records:
2819
2885
  body.append("<tr><td colspan='6' class='muted'>No gateway requests yet. Call a collection"
2820
2886
  f" at <code>{esc(gateway_url())}</code>.</td></tr>")
@@ -2829,7 +2895,10 @@ def gateway_card(limit: int = 30) -> str:
2829
2895
  carried none. Hover it for why.</dd>
2830
2896
  <dt>Asked by</dt><dd>The gateway key that made the request, or <code>local</code>
2831
2897
  when no key is asked for.</dd>
2832
- <dt>Owed</dt><dd>Seats without a verdict. Each is recorded as an unjudged
2898
+ <dt>Time</dt><dd>How long the caller waited: the seats run at once, so the
2899
+ slowest of them, plus the merge when the reply was merged. Hover for the two
2900
+ parts; a chip's own time is on the chip.</dd>
2901
+ <dt>Verdicts owed</dt><dd>A chip with no mark. Each is recorded as an unjudged
2833
2902
  offload, so rotation still counts it; a verdict tells the router whether
2834
2903
  it helped. The orchestrator that called can also record one:
2835
2904
  <code>python metrics/offload.py verdict &lt;call id&gt; --useful</code>.</dd>
@@ -2968,7 +3037,7 @@ def discover_model(provider: str, model: str) -> dict:
2968
3037
 
2969
3038
 
2970
3039
  def discover_card() -> str:
2971
- """Models worth adding, from models.dev and FreeLLMAPI (metrics/discover.py),
3040
+ """Models worth adding, from FreeLLMAPI and models.dev (metrics/discover.py),
2972
3041
  deduped. Read from the daily cache, so the page never waits on the network;
2973
3042
  built once per cache refresh. A click on a row opens its popup (bench.js),
2974
3043
  which asks /api/discover-model for the rest: its routes are where Add is."""
@@ -2976,7 +3045,8 @@ def discover_card() -> str:
2976
3045
  rows = [r for r in all_rows if r["free"] or r["reach"] in ("connected", "on bench")]
2977
3046
  hidden = len(all_rows) - len(rows)
2978
3047
  reach_css = {"on bench": "ok", "connected": "ok", "easy to add": "warn", "other": ""}
2979
- body = ["<table><thead><tr>", th("Model"), th("Provider"), th("Reach"), th("Price"), th("Context", True),
3048
+ body = ["<table><thead><tr>", th("Model"), th("Rank", True), th("Provider"), th("Reach"), th("Price"),
3049
+ th("Context", True),
2980
3050
  th("In → out"), th("Can"), th("Released"), th("Limits"), "<th></th></tr></thead><tbody>"]
2981
3051
  for r in rows:
2982
3052
  caps = [c for c, on in (("tools", r["tools"]), ("vision", r["vision"]),
@@ -2985,8 +3055,13 @@ def discover_card() -> str:
2985
3055
  price = ("free" if r["free"] else
2986
3056
  f"{cost.get('input', '?')} / {cost.get('output', '?')}" if cost else "paid")
2987
3057
  lim = r.get("limits") or {}
2988
- lim_text = " · ".join(f"{v:,} {k}" for k, v in lim.items()) if lim else ""
3058
+ lim_text = " · ".join([f"{v:,} {k}" for k, v in lim.items()]
3059
+ + ([f"{r['budget']} tokens a month"] if r.get("budget") else []))
2989
3060
  ctx = r.get("context")
3061
+ rank = (f"<td class='num' data-sort='{r['rank']}' title='FreeLLMAPI: smartest #{r['rank']}"
3062
+ + (f", fastest #{r['speed']}" if r.get("speed") else "")
3063
+ + (f", {esc(r['size'])}" if r.get("size") else "") + f"'>{r['rank']}</td>"
3064
+ if r.get("rank") is not None else "<td class='num muted' data-sort='99999'>—</td>")
2990
3065
  add = ("<button class='btn small' type='button'>Add…</button>"
2991
3066
  if any(not x["on_bench_as"] for x in r["routes"]) else "")
2992
3067
  body.append(
@@ -2997,8 +3072,8 @@ def discover_card() -> str:
2997
3072
  + f"<span class='conn'>{esc(r['model'])}"
2998
3073
  + (f" · on bench as {esc(r['on_bench_as'])}" if r.get("on_bench_as") else "")
2999
3074
  + (f" · also on bench as {esc(', '.join(r['also_on']))}" if r.get("also_on") else "")
3000
- + "</span></td>"
3001
- f"<td title='{esc(r['provider'])}'>{esc(r['provider_name'])}</td>"
3075
+ + "</span></td>" + rank
3076
+ + f"<td title='{esc(r['provider'])}'>{esc(r['provider_name'])}</td>"
3002
3077
  f"<td data-sort='{['on bench', 'connected', 'easy to add', 'other'].index(r['reach'])}'>"
3003
3078
  f"{chip(r['reach'], reach_css[r['reach']], r.get('api') or '')}</td>"
3004
3079
  f"<td title='per million tokens, input / output'>{esc(price)}</td>"
@@ -3015,31 +3090,40 @@ def discover_card() -> str:
3015
3090
  " provider or reach' aria-label='Filter rows'>"
3016
3091
  "<label><input type='checkbox' data-paid> Show paid (connected providers)</label></div>")
3017
3092
  key = """<dl>
3093
+ <dt>Rank</dt><dd>FreeLLMAPI's smartest-first order (1 is the smartest it lists); within
3094
+ each reach the list follows it. Hover for its fastest-first rank and size. A dash where
3095
+ only models.dev lists the model.</dd>
3018
3096
  <dt>Provider</dt><dd>Who serves the model: a row per provider, so the same model
3019
- from OpenRouter and from NVIDIA is two rows. Hover for its models.dev id.</dd>
3097
+ from OpenRouter and from NVIDIA is two rows. Hover for its id.</dd>
3020
3098
  <dt>Reach</dt><dd><em>on bench</em> already registered; <em>connected</em> this
3021
3099
  install has a route to the provider (an Open WebUI connection, or an endpoint of its
3022
3100
  own); <em>easy to add</em> an OpenAI-compatible API, so one new connection away (hover
3023
3101
  for its base URL); <em>other</em> a native API or an unvetted reseller.</dd>
3024
- <dt>Price</dt><dd>Free if models.dev prices it at 0 or FreeLLMAPI lists it (a catalog of
3025
- free tiers): models.dev shows the PAID price even where a free tier exists. Otherwise
3102
+ <dt>Price</dt><dd>Free if FreeLLMAPI lists it (a catalog of free tiers) or models.dev
3103
+ prices it at 0: models.dev shows the PAID price even where a free tier exists. Otherwise
3026
3104
  input / output per million tokens.</dd>
3105
+ <dt>Limits</dt><dd>The free tier's, from FreeLLMAPI: requests and tokens per minute (rpm,
3106
+ tpm) and per day (rpd, tpd), and roughly how many tokens a month that allows.</dd>
3027
3107
  <dt>New</dt><dd>First seen in either list in the last 7 days.</dd>
3028
3108
  <dt>Add</dt><dd>Click a row: its popup has everything known about the model and every
3029
3109
  route this install has to it, each with <em>Add via this</em>. <em>Via Open WebUI</em>
3030
3110
  adds the model to that connection's Model IDs (when it lists them), creates its model
3031
3111
  record public, tagged like the connection's other models and with their logo, then runs
3032
- the bench's onboarding (register, 4-call probe, suite), so it is in the chats too.
3112
+ the bench's onboarding as the confirm chooses (Onboard as, Vision model), so it is in
3113
+ the chats too.
3033
3114
  <em>Via LiteLLM</em> or <em>CLI Proxy API</em> adds it to that hub's connection (one more
3034
3115
  deployment, or the provider's models list); those hubs are asked when the popup opens.
3035
3116
  <em>Direct</em> onboards it on the provider's own endpoint: bench only. A new provider
3036
3117
  needs a connection or an endpoint first; hover its <em>easy to add</em> chip for the
3037
3118
  base URL.</dd>
3038
- <dt>Sources</dt><dd>models.dev and/or FreeLLMAPI's free catalog (named in the popup); a
3039
- model one provider offers in both is shown once. Both are refreshed at most daily.</dd>
3119
+ <dt>Sources</dt><dd>FreeLLMAPI's free catalog first: where it lists a model, its name,
3120
+ provider, context, tools, limits, ranks and quirks lead, and the popup links its
3121
+ freellmapi.co page. models.dev fills the rest and alone has the paid models, prices,
3122
+ base URLs and modalities. A model one provider offers in both is shown once. Both are
3123
+ refreshed at most daily.</dd>
3040
3124
  </dl>"""
3041
- return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from models.dev"
3042
- f" and FreeLLMAPI, deduped; {hidden:,} paid models on providers you have no route"
3125
+ return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from FreeLLMAPI"
3126
+ f" and models.dev, deduped; {hidden:,} paid models on providers you have no route"
3043
3127
  f" to are left out. Click a model for everything known about it and the routes it can be"
3044
3128
  f" added by.</p>{toolbar}"
3045
3129
  f"<div class='scroll'>{''.join(body)}</div>"
@@ -3083,8 +3167,11 @@ def collections_tab() -> str:
3083
3167
  # Not data-filter: that binds to the whole panel. The same match (any
3084
3168
  # text, case-insensitive), on the member list only; ticked ones stay shown.
3085
3169
  "<label class='field' for='c-member-filter'>Members</label>"
3170
+ # The panel as it will sit, drawn as the Collections list draws it
3171
+ # (bench.js recount): a seat per ticked member, explorer seats dashed.
3172
+ "<div id='c-seats' class='seats' aria-live='polite'></div>"
3086
3173
  "<input type='search' id='c-member-filter' class='member-filter' spellcheck='false' autocomplete='off'"
3087
- " placeholder='Filter by alias or served name' aria-controls='c-members' aria-describedby='c-count'>"
3174
+ " placeholder='Filter by model, provider or served name' aria-controls='c-members' aria-describedby='c-count'>"
3088
3175
  "<div id='c-members' class='members' role='group' aria-label='Members'></div>"
3089
3176
  "<p class='rules' id='c-count'></p>"
3090
3177
  "<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
@@ -3295,12 +3382,71 @@ def access_card() -> str:
3295
3382
  body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td>{esc(v)}</td>"
3296
3383
  f"<td class='mono set-by'>{'<br>'.join(esc(s) for s in src.split(', '))}</td></tr>"
3297
3384
  for k, v, src in rows)
3385
+ running, disk = on_disk()
3386
+ now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
3387
+ f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
3298
3388
  return ("<div class='panel'><h2>Server and access</h2><p class='muted'>How this install is reached and who"
3299
3389
  " may call it. Set in the environment, read at start: change one and restart. Shown here, never"
3300
3390
  " set here, since a page that could change its own login could open itself to anyone. No secret"
3301
3391
  " is shown, only whether one is set.</p>"
3302
3392
  f"<table class='facts'><thead><tr>{th('Setting')}{th('Now')}{th('Set by')}</tr></thead>"
3303
- f"<tbody>{body}</tbody></table></div>")
3393
+ f"<tbody>{body}</tbody></table>"
3394
+ # restart(): stopped and started again by serve.py's own parent process.
3395
+ f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
3396
+ " for an update or a setting"
3397
+ " above, whatever runs it (a service, the launcher, a container or a terminal). It waits for"
3398
+ " onboarding, a scheduled job and gateway calls in progress, or cuts them off if you insist; the"
3399
+ " page comes back in a few seconds.</p>"
3400
+ "<p><button class='btn' type='button' id='restart'>Restart Benchwarmers</button>"
3401
+ " <span id='restart-out' role='status'></span></p></div>")
3402
+
3403
+
3404
+ RESTART = 75 # the exit code serve.py's parent reads as "start me again"
3405
+ SERVER = None # the running ThreadingHTTPServer, set in __main__
3406
+ _answering = [0] # gateway calls being answered now
3407
+ _answering_lock = threading.Lock()
3408
+
3409
+
3410
+ def _follow_parent() -> None:
3411
+ """End with serve.py's parent however it went (a service stopped, a
3412
+ terminal closed), so no orphan keeps the port. Its process is waited on,
3413
+ not a pipe read: on Windows a thread blocked reading stdin stalls every
3414
+ subprocess started meanwhile (git, the onboarding, the jobs)."""
3415
+ ppid = int(os.environ.get("BENCH_PARENT") or 0)
3416
+ if not ppid:
3417
+ return
3418
+ if os.name == "nt":
3419
+ import ctypes
3420
+ k32 = ctypes.windll.kernel32
3421
+ handle = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
3422
+ if not handle:
3423
+ return # cannot watch it: stay up
3424
+ k32.WaitForSingleObject(handle, 0xFFFFFFFF) # until it ends
3425
+ else:
3426
+ while os.getppid() == ppid: # a new parent: the old one ended
3427
+ time.sleep(2)
3428
+ os._exit(0)
3429
+
3430
+
3431
+ def restart(force: bool = False, who: str = "ui") -> dict:
3432
+ """End this dashboard and start it again on the code now on disk: after
3433
+ an update, or a setting read at start. serve.py runs it as a child of its
3434
+ own parent process (see __main__), which starts it again, so this works
3435
+ however serve.py was started: Servy, the npm or pip launcher, Docker or a
3436
+ terminal. Refused while something would be cut off, unless `force`."""
3437
+ import scheduler
3438
+ if os.environ.get("BENCH_SERVED") != "1" or SERVER is None:
3439
+ return {"ok": False, "errors": ["this dashboard was not started by serve.py's own parent process,"
3440
+ " so nothing would start it again: restart it the way you started it"]}
3441
+ busy = ([f"onboarding is running ({sum(i['state'] in ('queued', 'running') for i in _OQ['items'])} left):"
3442
+ " Stop it in the queue, or wait"] if _OQ["worker"] is not None else []) \
3443
+ + ([f"a scheduled job is running: {', '.join(scheduler.RUNNING)}"] if scheduler.RUNNING else []) \
3444
+ + ([f"{_answering[0]} gateway call(s) are being answered"] if _answering[0] else [])
3445
+ if busy and not force:
3446
+ return {"ok": False, "busy": True, "errors": busy}
3447
+ _log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "", "restart", *on_disk())
3448
+ threading.Timer(0.5, SERVER.shutdown).start() # after this reply has gone
3449
+ return {"ok": True, "errors": [], "cut": busy}
3304
3450
 
3305
3451
 
3306
3452
  def save_scheduler(mode: str, jobs, who: str = "ui") -> dict:
@@ -3591,27 +3737,39 @@ def models_page() -> str:
3591
3737
  + "".join(cards))
3592
3738
 
3593
3739
 
3740
+ def quota_tiles() -> list[dict]:
3741
+ """metrics/quotaview.py's tiles, each named as the collection popup names
3742
+ its provider, unless the live read named the credentials behind it."""
3743
+ import quotaview
3744
+ return [dict(t, name=t["name"] or provider_label(t["tag"])) for t in quotaview.tiles()]
3745
+
3746
+
3747
+ def quota_op(which: str, tag) -> dict:
3748
+ """Refresh or Check now on one tile: {ok, errors, note, tile}; "order"
3749
+ saves the tiles' order (a list of tags, empty for the default sort)."""
3750
+ import quotaview
3751
+ if which == "order":
3752
+ return quotaview.save_order(tag)
3753
+ if tag not in {t["id"] for t in quota_tiles()}:
3754
+ return {"ok": False, "errors": [f"no tile called {tag!r}"]}
3755
+ try:
3756
+ r = (quotaview.refresh if which == "refresh" else quotaview.check)(tag)
3757
+ except (ValueError, OSError) as exc: # OSError: URLError and timeouts
3758
+ return {"ok": False, "errors": [str(exc)]}
3759
+ if not r.get("ok"):
3760
+ return {"ok": False, "errors": r.get("errors") or ["it failed"]}
3761
+ return {"ok": True, "errors": [], "note": r.get("note", ""),
3762
+ "tile": next((t for t in quota_tiles() if t["id"] == tag), None)}
3763
+
3764
+
3594
3765
  def quotas_page() -> str:
3595
- """What limits each connection, who is resting after one, what providers
3596
- last said was left (the built-in client's quota records, metrics/quota.py)
3597
- and what the bench itself sent (its usage ledger, metrics/ledger.py)."""
3766
+ """A tile per provider: what is left and when it resets, shaped by how the
3767
+ provider limits (metrics/quotaview.py), who is resting, Refresh and Check
3768
+ now; the resets of the next week; what the bench sent (its usage ledger,
3769
+ metrics/ledger.py); every limit known. bench.js draws tiles and charts."""
3598
3770
  import learn
3599
3771
  import ledger
3600
- import quota
3601
3772
  now = datetime.datetime.now().astimezone()
3602
- rec_rows = []
3603
- for r in sorted(quota.live(), key=lambda r: (r.get("connection", ""), r.get("alias", ""), r.get("metric", ""))):
3604
- try:
3605
- age = int((now - datetime.datetime.fromisoformat(r["observed_at"])).total_seconds() // 60)
3606
- reset = datetime.datetime.fromisoformat(r["reset_at"]).astimezone().strftime("%Y-%m-%d %H:%M")
3607
- except (KeyError, TypeError, ValueError):
3608
- continue
3609
- num = lambda v: f"{v:,}" if isinstance(v, int) else "—" # noqa: E731
3610
- rec_rows.append(f"<tr><td>{esc(r.get('connection', ''))}</td><td>{named(r.get('alias', ''))}</td>"
3611
- f"<td>{esc(r.get('metric', ''))} / {esc(r.get('window', ''))}</td>"
3612
- f"<td class='num'>{num(r.get('remaining'))}</td><td class='num'>{num(r.get('limit'))}</td>"
3613
- f"<td class='mono'>{esc(reset)}</td>"
3614
- f"<td>{esc(r.get('source', ''))}, {age} min ago</td></tr>")
3615
3773
  t = now.timestamp()
3616
3774
  sent: dict = {}
3617
3775
  for row in ledger.rows(min(t - 86400, ledger.month_start(t))):
@@ -3623,15 +3781,10 @@ def quotas_page() -> str:
3623
3781
  s[2], s[3] = s[2] + n, s[3] + tok
3624
3782
  sent_rows = [f"<tr><td>{esc(c)}</td>" + "".join(f"<td class='num'>{v:,}</td>" for v in s) + "</tr>"
3625
3783
  for c, s in sorted(sent.items())]
3626
- cool_rows = []
3627
- for alias, c in sorted(learn.cooldowns().items(), key=lambda kv: kv[1].get("until", "")):
3628
- try:
3629
- left = max(0, int((datetime.datetime.fromisoformat(c["until"]) - now).total_seconds() // 60))
3630
- except (KeyError, ValueError, TypeError):
3631
- left = 0
3632
- cool_rows.append(f"<tr><td>{named(alias)}</td><td>{esc(catalog.connection_of(alias))}</td>"
3633
- f"<td class='num' data-sort='{left}'>{left} min</td>"
3634
- f"<td>{'stated by the provider' if c.get('stated') else 'default wait'}</td></tr>")
3784
+ data = json.dumps({"tiles": quota_tiles(),
3785
+ "sent": [{"provider": provider_label(c) if c != "?" else "?", "day": s[0], "month": s[2]}
3786
+ for c, s in sent.items() if s[2]]},
3787
+ ensure_ascii=False).replace("</", "<\\/")
3635
3788
  entered = catalog.connection_limits()
3636
3789
  keys = catalog.CONNECTION_LIMIT_KEYS
3637
3790
  lim_rows = [f"<tr><td>{esc(conn)}</td>" + "".join(
@@ -3646,40 +3799,42 @@ def quotas_page() -> str:
3646
3799
  + "".join(f"<td class='num'>{ev['value']:,}</td>" if kk == k
3647
3800
  else "<td class='num muted'>—</td>" for kk in keys)
3648
3801
  + f"<td>learned {esc(ev.get('at', '')[:10])} ({esc(ev.get('where', ''))})</td></tr>")
3802
+ sent_table = ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
3803
+ + th("Requests, this month", True) + th("Tokens, this month", True)
3804
+ + f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
3805
+ if sent_rows else "<p class='muted'>No call in the ledger this month.</p>")
3806
+ sent_key = ("<p>One row per provider, most requests this month first. The pale bar is this month's"
3807
+ " requests, the dark one inside it the last 24 hours'; the label reads 24 h / month."
3808
+ " Retries count. Calls through the llm CLI are not in the ledger.</p>")
3649
3809
  return (
3650
- "<div class='panel'><h2>Quota management</h2><p class='sub'>What is left on each connection and"
3651
- " when it resets, from the rate-limit headers and 429 replies the built-in client sees, and what"
3652
- " the bench sent itself, from its usage ledger; who is resting after a rate limit, and every"
3653
- " limit known. The built-in client keeps to all of these. Calls through the llm CLI are in"
3654
- " neither record. Quotas of OAuth credentials stay in CLI Proxy API's own console.</p></div>"
3655
- "<div class='panel'><h3 class='chart-head'>What providers said is left · " + str(len(rec_rows)) + "</h3>"
3656
- + ("<table><thead><tr>" + th("Connection") + th("Model") + th("Counts") + th("Left", True)
3657
- + th("Of", True) + th("Resets") + th("From") + f"</tr></thead><tbody>{''.join(rec_rows)}</tbody></table>"
3658
- if rec_rows else "<p class='muted'>Nothing in force: no provider has stated a limit whose reset"
3659
- " is still ahead.</p>")
3660
- + "</div>"
3661
- "<div class='panel'><h3 class='chart-head'>What the bench sent</h3><p class='sub'>Requests"
3662
- " (retries included) and tokens per connection, from the usage ledger.</p>"
3663
- + ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
3664
- + th("Requests, this month", True) + th("Tokens, this month", True)
3665
- + f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
3666
- if sent_rows else "<p class='muted'>No call in the ledger this month.</p>")
3667
- + "</div>"
3668
- "<div class='panel'><h3 class='chart-head'>Resting after a rate limit · " + str(len(cool_rows)) + "</h3>"
3669
- + ("<table><thead><tr>" + th("Model") + th("Connection") + th("Back in", True) + th("Wait")
3670
- + f"</tr></thead><tbody>{''.join(cool_rows)}</tbody></table>" if cool_rows
3671
- else "<p class='muted'>None: every model is free to call.</p>")
3672
- + "</div>"
3673
- "<div class='panel'><h3 class='chart-head'>Known limits</h3><p class='sub'>How much each connection"
3674
- " or model allows (tokens or requests per minute or day), so the bench can space its calls."
3675
- " Two sources: what you enter per connection (Endpoints, Rate limits per connection), and what a"
3676
- " provider states in a rate-limit error, which the bench writes down by itself. The list grows on"
3677
- " its own as models meet their limits. A model's own limits (its popup) win, and learned ones"
3678
- " only ever lower a limit.</p>"
3810
+ "<div class='panel'><h2>Quota</h2><p class='sub'>What is left with each provider and when it"
3811
+ " resets, and who is resting after a rate limit. CLI Proxy API's credentials and OpenRouter are"
3812
+ " read live by Refresh, which spends nothing; every other tile is what the provider last said"
3813
+ " in its replies to the bench, and Check now, where it is offered, asks with one request."
3814
+ " Drag a tile, or move it with the arrow keys, to keep it where you want it.</p>"
3815
+ "<div class='list-actions'><button class='btn' id='q-all'>Refresh all live</button>"
3816
+ "<button class='btn' id='q-reorder'>Sort: out first, then soonest reset</button>"
3817
+ "<span class='done' id='q-done' aria-live='polite'></span>"
3818
+ "<span class='errors' id='q-errors' aria-live='polite'></span></div>"
3819
+ "<div class='q-tiles' id='q-tiles'></div></div>"
3820
+ "<div class='panel'><h3 class='chart-head'>Resets, next 7 days</h3><p class='sub'>A dot where a"
3821
+ " limit resets (a daily one every day), a bar while a model rests, the line is now.</p>"
3822
+ "<div class='chart' id='chart-resets'></div></div>"
3823
+ + chart_card("What the bench sent", "Requests per provider: this month, and the last 24 hours inside it.",
3824
+ "chart-sent", sent_table, sent_key, retired=False)
3825
+ + "<details class='panel'><summary><b>Known limits</b></summary><p class='sub'>How much each"
3826
+ " connection or model allows (tokens or requests per minute or day), so the bench can space its"
3827
+ " calls. Two sources: what you enter per connection (Endpoints, Rate limits per connection), and"
3828
+ " what a provider states in a rate-limit error, which the bench writes down by itself. A model's"
3829
+ " own limits (its popup) win, and learned ones only ever lower a limit.</p>"
3679
3830
  + ("<table><thead><tr>" + th("Connection or model") + "".join(th(k, True) for k in keys) + th("From")
3680
3831
  + f"</tr></thead><tbody>{''.join(lim_rows)}</tbody></table>" if lim_rows
3681
3832
  else "<p class='muted'>None known yet.</p>")
3682
- + "</div>")
3833
+ + "</details>"
3834
+ "<dialog id='q-models' aria-labelledby='q-models-h'><h3 id='q-models-h'></h3>"
3835
+ "<p class='sub' id='q-models-sub'></p><div class='scroll' id='q-models-list'></div>"
3836
+ "<div class='list-actions'><button class='btn' id='q-models-close'>Close</button></div></dialog>"
3837
+ f"<script type='application/json' id='quota-data'>{data}</script>")
3683
3838
 
3684
3839
 
3685
3840
  def _log_rows(path: pathlib.Path, limit: int = 200) -> list[str]:
@@ -3751,6 +3906,14 @@ RELEASE, RELEASE_DATE, COMMIT = version()
3751
3906
  VERSION = f"{RELEASE} {RELEASE_DATE}".strip() # the X-Benchwarmers-Version header
3752
3907
 
3753
3908
 
3909
+ def on_disk() -> tuple[str, str]:
3910
+ """(running, on disk): what this process started from and what a restart
3911
+ would start, by commit, or by the VERSION file where there is no git."""
3912
+ if COMMIT:
3913
+ return COMMIT, _git("log", "-1", "--format=%h") or COMMIT
3914
+ return RELEASE, read(ROOT / "VERSION").strip()
3915
+
3916
+
3754
3917
  def info_page() -> str:
3755
3918
  import scheduler
3756
3919
  import client as model_client
@@ -3859,6 +4022,7 @@ def editor() -> str:
3859
4022
  # The page's own confirm (bench.js ask): window.confirm can be blocked.
3860
4023
  "<dialog id='ask-dialog' aria-labelledby='ask-text'>"
3861
4024
  "<p id='ask-text' style='white-space:pre-line'></p>"
4025
+ f"<div id='ask-onboard' hidden>{onboard_choice('ask')}</div>" # Discover's Add only
3862
4026
  "<div class='actions'><button class='btn' id='ask-no' type='button'>Cancel</button>"
3863
4027
  "<button class='btn primary' id='ask-yes' type='button'>OK</button></div></dialog>"
3864
4028
  # onboarding
@@ -3876,13 +4040,7 @@ def editor() -> str:
3876
4040
  " <button class='linkish' type='button' id='o-refresh'>Refresh</button></p>"
3877
4041
  "<div class='picker' id='o-picker' role='group' aria-label='Served models'></div>"
3878
4042
  # serve.ONBOARD_MODES: how much each onboarding spends.
3879
- "<label class='check'>Onboard as <select id='o-mode'>"
3880
- "<option value='full'>Full: probe, then the suite now</option>"
3881
- "<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
3882
- "<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
3883
- " (metered, capped or trial keys)</option></select></label>"
3884
- "<label class='check'><input type='checkbox' id='o-vision'> Vision model: a seat only for questions"
3885
- " about images, and its suite is the vision tasks</label>"
4043
+ f"{onboard_choice('o')}"
3886
4044
  "</div>"
3887
4045
  "<div id='o-run' hidden><p class='served' id='o-result'></p><ol class='steps' id='o-steps'></ol>"
3888
4046
  "<details><summary class='muted'>Full log</summary><pre class='log' id='o-log'></pre></details></div>"
@@ -4122,7 +4280,13 @@ class Handler(gateway.Handler):
4122
4280
  """
4123
4281
  path = self.path.split("?", 1)[0]
4124
4282
  if guard.gateway_route(path):
4125
- return super().do_POST()
4283
+ with _answering_lock:
4284
+ _answering[0] += 1 # restart() waits for these
4285
+ try:
4286
+ return super().do_POST()
4287
+ finally:
4288
+ with _answering_lock:
4289
+ _answering[0] -= 1
4126
4290
  if path == "/login":
4127
4291
  return self._login()
4128
4292
  ops = {"/api/model": lambda p: change_model(str(p.get("alias", "")),
@@ -4134,7 +4298,9 @@ class Handler(gateway.Handler):
4134
4298
  "/api/retry": lambda p: retry_model(str(p.get("alias") or "")),
4135
4299
  "/api/discover/add": lambda p: discover_add(str(p.get("connection") or ""),
4136
4300
  str(p.get("model") or ""),
4137
- endpoint=str(p.get("endpoint") or "")),
4301
+ endpoint=str(p.get("endpoint") or ""),
4302
+ mode=str(p.get("mode") or "full"),
4303
+ vision=p.get("vision") is True),
4138
4304
  "/api/onboard": lambda p: start_onboard(str(p.get("served", "")),
4139
4305
  str(p.get("endpoint", "")),
4140
4306
  mode=str(p.get("mode") or "full"),
@@ -4179,7 +4345,11 @@ class Handler(gateway.Handler):
4179
4345
  "/api/gateway-key": lambda p: make_gateway_key(str(p.get("name") or ""), p.get("collections"),
4180
4346
  p.get("rpm")),
4181
4347
  "/api/gateway-key/remove": lambda p: revoke_gateway_key(str(p.get("name") or "")),
4182
- "/api/theme": lambda p: save_theme(str(p.get("theme") or ""))}
4348
+ "/api/theme": lambda p: save_theme(str(p.get("theme") or "")),
4349
+ "/api/quota/refresh": lambda p: quota_op("refresh", str(p.get("tag") or "")),
4350
+ "/api/quota/check": lambda p: quota_op("check", str(p.get("tag") or "")),
4351
+ "/api/quota/order": lambda p: quota_op("order", p.get("order")),
4352
+ "/api/restart": lambda p: restart(p.get("force") is True)}
4183
4353
  # SAME ORIGIN ONLY. Any page open in the browser can POST a plain form
4184
4354
  # to 127.0.0.1, and this used to parse whatever arrived as JSON -- so a
4185
4355
  # website could retire models, and now could set a key. The checks are
@@ -4276,6 +4446,19 @@ class Handler(gateway.Handler):
4276
4446
 
4277
4447
 
4278
4448
  if __name__ == "__main__":
4449
+ if os.environ.get("BENCH_SERVED") != "1":
4450
+ # The dashboard runs as this process's child, so Restart (restart())
4451
+ # can end it and have it started again on the code now on disk,
4452
+ # whatever started serve.py. The child ends with this one (_follow_parent).
4453
+ env = {**os.environ, "BENCH_SERVED": "1", "BENCH_PARENT": str(os.getpid())}
4454
+ try:
4455
+ while (code := subprocess.call([sys.executable, os.path.abspath(__file__), *sys.argv[1:]],
4456
+ env=env, stdin=subprocess.DEVNULL)) == RESTART:
4457
+ print("Benchwarmers: restarting on the code now on disk", flush=True)
4458
+ except KeyboardInterrupt:
4459
+ code = 0
4460
+ sys.exit(code)
4461
+ threading.Thread(target=_follow_parent, daemon=True).start()
4279
4462
  address = guard.bind()
4280
4463
  guard.startup(address) # beyond this machine, only with an admin token
4281
4464
  shown = "localhost" if address == "127.0.0.1" else address
@@ -4284,4 +4467,6 @@ if __name__ == "__main__":
4284
4467
  paths.make() # a fresh data folder's layout, before any writer
4285
4468
  import scheduler
4286
4469
  scheduler.start() # off unless config/scheduler.json says otherwise
4287
- ThreadingHTTPServer((address, PORT), Handler).serve_forever()
4470
+ SERVER = ThreadingHTTPServer((address, PORT), Handler)
4471
+ SERVER.serve_forever() # returns only when restart() shuts it down
4472
+ sys.exit(RESTART)