@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/GATEWAY.md CHANGED
@@ -389,7 +389,7 @@ the panels' verdicts. A verdict on a conversation is optional
389
389
  (`POST /bench/verdict` with that call id); the offload alone already counts
390
390
  toward rotation. The Gateway card lists route conversations in their own
391
391
  table (when, who asked, the first message, who answered with ✓/✗, turns,
392
- tool calls, failovers) and none of them adds to **Owed**.
392
+ tool calls, failovers) and none of them counts as a verdict owed.
393
393
 
394
394
  **Shadows (later, opt-in).** A shadow seat would answer the same turn in the
395
395
  background for evaluation only, after the caller has its answer. The place
@@ -515,9 +515,9 @@ key limited to its collection and a rate, or keep the key on the app's server.
515
515
  | Shape picker | Optional decision model (above). |
516
516
  | Call log | `runs/gateway/calls.jsonl`, gitignored: every seat's answer, status, timing and call id. |
517
517
  | Evidence | Each seat is recorded in `metrics/offloads.jsonl` as an unjudged offload when it answers; `offload.py verdict <call id>` or `POST /bench/verdict` replaces it. |
518
- | Collections tab | The collections and their form, then the gateway's recent requests: every seat's status and verdict, and how many verdicts are owed. Clicking a seat opens its answer and the verdict form. |
519
- | Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily (scheduled task WRBench-Catalog); a free model whose price goes above zero raises a "went paid" alert. |
520
- | Discover tab | `metrics/discover.py`: models.dev and FreeLLMAPI merged into one list of models worth adding, free first, marked by whether you can already reach the provider and whether the model is on the bench (on another provider counts as "also on bench"). |
518
+ | Collections tab | The collections, each drawn as its panel sits, and their form (one row per model, a chip per provider). Then the gateway's recent requests: every seat's status and verdict, how long the caller waited (Time), and how many verdicts are owed. Clicking a seat opens its answer and the verdict form. |
519
+ | Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily by the built-in scheduler; a free model whose price goes above zero raises a "went paid" alert. |
520
+ | Discover tab | `metrics/discover.py`: FreeLLMAPI (leading) and models.dev merged into one list of models worth adding, free first, FreeLLMAPI's smartest first, marked by whether you can already reach the provider and whether the model is on the bench (on another provider counts as "also on bench"). |
521
521
  | Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
522
522
  | MCP server | `mcp_server.py`: the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`. Two transports for one `answer()`: `POST /mcp` (or `/v1/mcp`) on either port, streamable HTTP with a JSON reply per request, its tools calling the server's own routes over loopback with the caller's credential and Host; and stdio (`benchwarmers mcp`), a caller like any other over HTTP (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`). The Claude Code plugin in `plugin/` connects to `/mcp` and adds a command, a skill and a `Stop` hook (`plugin/hooks/hooks.json`, an `mcp_tool` hook) that calls `pending_verdicts` with the session's `transcript_path`: the call ids in the transcript (Claude Code keeps no `Mcp-Session-Id`, so the transcript is the record of whose panels they were), of which the call log says which are answered seats with no verdict (`POST /bench/pending`), returned once as `additionalContext`. A server on another machine cannot read the transcript, so there the hook says nothing. A tool call answers as SSE with progress notifications, which reset Claude Code's 300-second idle limit. |
523
523
 
@@ -529,7 +529,11 @@ key limited to its collection and a rate, or keep the key on the app's server.
529
529
  - **Every seat is named** in the response, including ones that failed or came
530
530
  back empty, because who answered is itself evidence about the roster.
531
531
  - **Free models only**, so rate limits are the constraint; a failed seat is
532
- reported and logged, not retried into the limit.
532
+ reported and logged, not retried into the limit. A model the bench already
533
+ knows has no room (a limit or quota it has counted, `metrics/pacing.py`) is
534
+ not seated at all: an explorer seat goes to a model with room now, a member
535
+ is named as not asked when its wait would outlast the seat, and a request
536
+ with no seat left gets a 429.
533
537
  - **Privacy:** prompts go only to providers the bench already uses.
534
538
  - **Bind to 127.0.0.1**, as the dashboard does. Elsewhere only with an admin
535
539
  token set, and then every call carries a gateway key made on the
package/PRIVACY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Privacy policy
2
2
 
3
- *Effective 23 October 2026.*
3
+ *Effective 29 October 2026.*
4
4
 
5
5
  Benchwarmers runs on your own machine or server. It has no account, no
6
6
  telemetry and no server of its own, and nothing you do with it reaches
@@ -13,10 +13,16 @@ Wendell Research.
13
13
  That includes what the gateway relays for your apps, and the calls the
14
14
  probes, the suite and onboarding make. Each endpoint's own privacy policy
15
15
  applies to what it receives; some free tiers keep prompts or train on them.
16
- - **Catalog reads**, carrying nothing of yours: models.dev and FreeLLMAPI
17
- (at most once a day) and OpenRouter's public model list.
16
+ - **Catalog reads**, carrying nothing of yours: FreeLLMAPI (its feed and its
17
+ site's sitemap) and models.dev (at most once a day) and OpenRouter's public
18
+ model list.
18
19
  - **Sign-ins**, only when you choose one: the provider's own sign-in page, in
19
20
  your browser, and the provider's token endpoint.
21
+ - **Quota reads**, only when you press Refresh on Quota management, carrying
22
+ no prompt:
23
+ - OpenRouter's key status, asked with your key.
24
+ - For CLI Proxy API's sign-ins, through the proxy, the quota endpoint of the
25
+ provider each sign-in belongs to (Google's, ChatGPT's).
20
26
  - **Charts**: the dashboard's page loads Observable Plot from jsDelivr, at a
21
27
  pinned version, in your browser.
22
28
  - **Real-bug task seeding**, only when you run `suite/rebench.py`: dataset rows
package/README.md CHANGED
@@ -160,8 +160,9 @@ dependencies either way: the app is the Python standard library.
160
160
  4. **Onboard a few models** from it ("Onboard a model"): tick one or several;
161
161
  each is registered, probed and run through the suite, one at a time, with
162
162
  the progress beside the button. **Quick** probes now and leaves the suite
163
- to the scheduled passes; **Quick + ad hoc** also keeps the bench from ever
164
- calling the model on its own, for a metered or trial key. Tick **Vision
163
+ to the scheduled passes; **+ ad hoc** (with Full or Quick) keeps the bench
164
+ from ever calling the model on its own afterwards, for a metered or trial
165
+ key. Tick **Vision
165
166
  model** for one that reads images and little else: it sits only for
166
167
  questions about images, and is tested on the vision tasks, not the code ones.
167
168
 
@@ -194,10 +195,12 @@ dependencies either way: the app is the Python standard library.
194
195
  (requests a minute, day or month; tokens a minute or day). They are kept per
195
196
  connection, since models behind one key share them, and enforced: a call
196
197
  waits when the wait fits, or is refused before it goes out. Every call lands
197
- in a monthly usage ledger with its tokens, and the Quotas page shows what is
198
- left and when it resets.
199
- - **Discover.** models.dev and FreeLLMAPI, merged into one list of models
200
- worth adding, each with the routes this install has to it.
198
+ in a monthly usage ledger with its tokens. Quota management has a tile per
199
+ provider showing what is left, when it resets and who is resting. OpenRouter
200
+ and CLI Proxy API's sign-ins are read live, at no cost to quota.
201
+ - **Discover.** FreeLLMAPI's free catalog, with models.dev for the details
202
+ and paid models it lacks, merged into one list of models worth adding,
203
+ smartest first, each with the routes this install has to it.
201
204
 
202
205
  ## Asking a panel
203
206
 
@@ -248,6 +251,7 @@ without it: ask and judge, and run the bench itself. It is a client of a
248
251
  running server, so the same commands work against one on another machine.
249
252
 
250
253
  bench status what is running, and where
254
+ bench restart start it again on the code now on disk
251
255
  bench collections what you can ask
252
256
  bench ask core "Where can this return a stale value?" -f cache.py
253
257
  git diff | bench ask core "Review this diff" a pipe is the prompt
@@ -432,9 +436,13 @@ reaches Wendell Research. What it sends, and where:
432
436
  a hub such as Open WebUI, or a local runtime. That includes the probes, the
433
437
  suite and onboarding, which spend calls on your keys. Add only endpoints you
434
438
  are content to send that work to.
435
- - **Catalog reads**, no key and nothing of yours: models.dev and FreeLLMAPI
436
- once a day, and OpenRouter's public model list.
439
+ - **Catalog reads**, no key and nothing of yours: FreeLLMAPI (its feed and
440
+ its site's sitemap) and models.dev once a day, and OpenRouter's public
441
+ model list.
437
442
  - **Sign-ins**, only when you choose one: the provider's own sign-in page.
443
+ - **Quota reads**, only when you press Refresh on Quota management, with no
444
+ prompt: OpenRouter's key status, with your key. CLI Proxy API asks each
445
+ sign-in's own provider for its quota.
438
446
  - **Charts**: the dashboard's browser page loads Observable Plot from jsDelivr
439
447
  at a pinned version.
440
448
  - **Seeding real-bug tasks**, only when you run `suite/rebench.py`: the
package/USAGE.md CHANGED
@@ -14,6 +14,7 @@ coding agent doing it with you); the gateway's whole contract is in
14
14
  - [Connecting an app](#connecting-an-app)
15
15
  - [Recording verdicts](#recording-verdicts)
16
16
  - [Discover: finding models to add](#discover-finding-models-to-add)
17
+ - [Quota management](#quota-management)
17
18
  - [The scheduler](#the-scheduler)
18
19
  - [Your data folder](#your-data-folder)
19
20
 
@@ -25,18 +26,25 @@ coding agent doing it with you); the gateway's whole contract is in
25
26
  | --- | --- | --- |
26
27
  | Observe | **At a glance** | Every model on the bench: whether it works right now, its role, and its connection. Click a name for its popup: role, alias, facts, options, retire. |
27
28
  | | **Benchmarks** | How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, and speed. |
28
- | | **Quota management** | What limits each connection, and which models are resting after hitting one. |
29
+ | | **Quota management** | A tile per provider: what is left, when it resets, and which of its models are resting. Then the week's resets, and what the bench sent. |
29
30
  | | **Logs** | Gateway calls (who sat and how it went, never the prompt), changes made from the dashboard, the scheduler, and onboarding runs. |
30
31
  | Gateway | **Endpoints** | Where models are served from. Add an endpoint (a provider's sign-in, or any OpenAI-compatible API key), change a key, and see what each one serves. Also your own app registrations and rate limits per connection. |
31
32
  | | **Models** | Every model on the bench, by the connection it comes from. |
32
33
  | | **Collections** | The named panels your apps ask for, and the gateway's settings. |
33
34
  | | **Discover** | Models worth adding, from public catalogs, with the ways this install can reach each one. |
34
- | Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app, and how the install is reached (shown only). |
35
+ | Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app, how the install is reached (shown only), and Restart. |
35
36
  | | **Info** | Version, Python, data folder and counts. |
36
37
 
37
38
  "Action needed", above the pages, lists what will not fix itself, such as a
38
- model about to be retired, with Retry, Open and Retire now beside it. Hide
39
- folds it away until something new turns up.
39
+ model about to be retired, naming each model and where it comes from, with
40
+ Retry, Open and Retire now beside it. Hide folds it away until something new
41
+ turns up; an alert whose models have all been retired goes at once.
42
+
43
+ **Restart** (Settings, Server and access), or `bench restart`, starts the
44
+ dashboard again on the code now on disk, after an update. It waits for
45
+ onboarding, a scheduled job and gateway calls in progress (`--force` cuts them
46
+ off), and Settings says when newer code is on disk. It needs no service
47
+ manager: `serve.py` restarts its own dashboard.
40
48
 
41
49
  ## Onboarding models
42
50
 
@@ -44,18 +52,22 @@ folds it away until something new turns up.
44
52
  not on the bench yet. Search, and tick one or several: the ticks survive a new
45
53
  search, so you can gather models from different connections in one go.
46
54
  Dismiss hides one you never want offered (an image model, say); the dismissed
47
- list brings it back.
55
+ list brings it back. Each endpoint's list is kept an hour, with its age and a
56
+ Refresh, and a model already queued or onboarding is not offered again.
48
57
 
49
58
  Each model is registered, probed with four short calls, and, if it answers,
50
59
  run through the suite. **Onboard as**, under the list, chooses how much that
51
60
  spends:
52
61
 
53
62
  - **Full**: the probe, then the suite now.
63
+ - **Full + ad hoc**: the probe and the suite now, once, so routing has scores
64
+ for it; after that the bench never calls the model on its own (no explorer
65
+ seat, no scheduled runs). A collection that lists it still calls it.
54
66
  - **Quick**: the probe now; the scheduled suite passes fill the suite in, six
55
67
  tasks a run. The same calls, spread out.
56
- - **Quick + ad hoc**: the probe only, and the bench never calls the model on
57
- its own (no explorer seat, no scheduled runs). The one that saves calls, for
58
- a metered, capped or trial key. A collection that lists it still calls it.
68
+ - **Quick + ad hoc**: the probe only, and then ad hoc as above. The one that
69
+ spends least, for a metered, capped or trial key; its suite stays empty,
70
+ since the scheduled passes leave ad hoc models alone.
59
71
 
60
72
  **Vision model**, the box beside it, gives the model role **vision** as it is
61
73
  registered: it sits only for requests that carry images, and its suite is the
@@ -64,7 +76,7 @@ key) rather than the text and code ones. Full runs them now, Quick through the
64
76
  scheduled passes. For a model that reads images and little else; a model that
65
77
  does everything and also takes images stays general and gets both.
66
78
 
67
- `bench onboard <served> [--quick [--ad-hoc]] [--vision]` does the same from a terminal.
79
+ `bench onboard <served> [--quick] [--ad-hoc] [--vision]` does the same from a terminal.
68
80
  Onboarding takes minutes and spends calls, so models run **one at a time**,
69
81
  in a queue the dashboard keeps:
70
82
 
@@ -91,6 +103,15 @@ everything else well stays `general`.
91
103
  A collection is a named panel: the models it always includes, the shape of
92
104
  work it is for (review, design, enumeration, ...), and how its reply comes
93
105
  back. On the **Collections** page, **New collection**, then pick the members.
106
+ Each collection is drawn as its panel sits: a seat per member, the explorer
107
+ seats dashed, and the merger after an arrow.
108
+
109
+ In the popup, the panel's seats are on top, and the member list has one row
110
+ per model with its context, vision and tools, and price. A model served by
111
+ several providers is still one row, with a chip per provider: tick the chip
112
+ of the route you want. Ticking two is allowed, and the count says so. The tags
113
+ grey out the models that do not fit them, as before.
114
+
94
115
  Each call also seats explorers, the models unused longest, up to the gateway's
95
116
  limits (Gateway settings, on the same page: how many calls a request may make,
96
117
  and how many of them explore).
@@ -191,17 +212,55 @@ are often wrong together.
191
212
 
192
213
  ## Discover: finding models to add
193
214
 
194
- **Discover** merges models.dev and FreeLLMAPI into one list, free models
195
- first, with what is new this week badged. A row opens its popup: what the
196
- model takes and does, its price, and every route this install has to it, each
197
- with an **Add via this**. Adding through a hub (Open WebUI, LiteLLM, CLI Proxy
215
+ **Discover** leads with FreeLLMAPI's catalog of free tiers and fills in from
216
+ models.dev, which alone has prices, paid models and providers' base URLs. One
217
+ list: free models first, then by reach, and within each reach FreeLLMAPI's
218
+ smartest first (the **Rank** column), then what it does not rank, what is new
219
+ this week first and badged. A row opens its popup: what
220
+ the model takes and does, its limits and monthly budget, FreeLLMAPI's known
221
+ quirks for it, links to its freellmapi.co page and models.dev, and every route
222
+ this install has to it, each with an **Add via this**. Adding through a hub (Open WebUI, LiteLLM, CLI Proxy
198
223
  API) puts it in that hub too; adding direct registers it on its own endpoint
199
- only. Either way the onboarding joins the same queue.
224
+ only. Either way the onboarding joins the same queue, with the same
225
+ **Onboard as** and **Vision model** choice as the Onboard dialog, asked in the
226
+ confirm before anything is added.
200
227
 
201
228
  Some providers' free models work only inside their own app; Discover leaves
202
229
  those out and keeps their paid models. Settings, **Free only in their own
203
230
  app**, switches a provider either way or adds one.
204
231
 
232
+ ## Quota management
233
+
234
+ One tile per provider a bench model arrives through, shaped by how that
235
+ provider limits:
236
+ - **Windows**: CLI Proxy API's sign-ins have a 5-hour and a weekly limit per
237
+ model group. Other providers have the windows their rate-limit headers state.
238
+ - **Pool**: OpenRouter's free requests a day, shared by its models, with a bar
239
+ per key.
240
+ - **Each model's last day**: shown where the provider states nothing.
241
+
242
+ Each tile shows when its limits reset and which of its models are resting
243
+ after a rate limit. **Models** lists the provider's bench models, whether each
244
+ is free to call now, and its last 24 hours of calls.
245
+
246
+ **Refresh never spends quota.** On OpenRouter it asks the key what is left. On
247
+ CLI Proxy API's sign-ins it asks the proxy for each one's quota, as its own
248
+ console does; this needs CLI Proxy API on the Endpoints page as a hub, with
249
+ its management key. Every other tile re-reads what the provider last said in
250
+ its replies to the bench. **Check now** sends one 16-token request to read the
251
+ reply's limit headers. It is offered only where the provider has sent them
252
+ before.
253
+
254
+ Tiles sort with the ones that are out first, then the soonest reset. Drag a
255
+ tile, or move it with the arrow keys, and the order is kept; **Sort** brings
256
+ the default order back.
257
+
258
+ Under the tiles are:
259
+ - the next 7 days' resets, and when each resting model is back
260
+ - **What the bench sent**: requests per provider this month and in the last 24
261
+ hours, as a chart with its table
262
+ - the known limits, folded away
263
+
205
264
  ## The scheduler
206
265
 
207
266
  The recurring jobs (the morning availability probe, the catalog refresh, the
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.5.0
package/cli.py CHANGED
@@ -258,9 +258,7 @@ def cmd_onboard(args) -> None:
258
258
  reply = call("GET", "/api/onboard-queue") if verb == "status" else \
259
259
  call("POST", f"/api/onboard-queue/{verb}", {})
260
260
  else:
261
- if args.ad_hoc and not args.quick:
262
- sys.exit("--ad-hoc goes with --quick: an ad hoc model gets no suite run of its own")
263
- mode = "quick-adhoc" if args.ad_hoc else "quick" if args.quick else "full"
261
+ mode = ("quick" if args.quick else "full") + ("-adhoc" if args.ad_hoc else "")
264
262
  items = [{"served": s, "endpoint": args.endpoint or "", "mode": mode, "vision": args.vision}
265
263
  for s in args.served]
266
264
  reply = call("POST", "/api/onboard-queue", {"items": items})
@@ -385,6 +383,13 @@ def cmd_verdict(args) -> None:
385
383
  f" {len(body['real'])} real, {len(body['false'])} false"))
386
384
 
387
385
 
386
+ def cmd_restart(args) -> None:
387
+ """serve.restart: refused while work is in progress, unless --force."""
388
+ reply = call("POST", "/api/restart", {"force": args.force})
389
+ out(args, reply, lambda r: done(r, "restarting on the code now on disk; back in a few seconds"
390
+ + (f" (cut off: {'; '.join(r['cut'])})" if r.get("cut") else "")))
391
+
392
+
388
393
  def cmd_endpoints(args) -> None:
389
394
  reply = call("GET", "/api/endpoints")
390
395
  out(args, reply, lambda r: table(r["endpoints"], ["name", "base", "kind", "hub", "default", "key_set", "models"],
@@ -547,6 +552,9 @@ def parser() -> argparse.ArgumentParser:
547
552
  return act
548
553
 
549
554
  cmd("status", cmd_status, "what is running, and where")
555
+ s = cmd("restart", cmd_restart, "start the dashboard again on the code now on disk")
556
+ s.add_argument("--force", action="store_true",
557
+ help="even while onboarding, a scheduled job or gateway calls are in progress (cuts them off)")
550
558
 
551
559
  s = cmd("ask", cmd_ask, "ask a collection: every seat answers")
552
560
  s.add_argument("collection", help="a collection, e.g. core (bench collections)")
@@ -598,7 +606,8 @@ def parser() -> argparse.ArgumentParser:
598
606
  s.add_argument("--quick", action="store_true",
599
607
  help="probe only; the scheduled suite passes fill the suite in later")
600
608
  s.add_argument("--ad-hoc", action="store_true",
601
- help="with --quick: the bench never calls it on its own (metered, capped or trial keys)")
609
+ help="the bench never calls it on its own afterwards (metered, capped or trial keys);"
610
+ " without --quick the suite still runs once now")
602
611
  s.add_argument("--vision", action="store_true",
603
612
  help="role vision: a seat only for questions about images, and its suite is the vision tasks")
604
613
 
package/gateway.py CHANGED
@@ -330,6 +330,22 @@ def retired_why(entry: dict) -> str:
330
330
  " (Restore in its model popup brings it back)")
331
331
 
332
332
 
333
+ def refused_now(aliases, est_tokens: int, within: float) -> dict[str, str]:
334
+ """alias -> why, for models whose known rate limits or quota leave no room
335
+ for a call within `within` seconds (metrics/pacing.held): asked, they
336
+ would only bring back the bench's own refusal, so they are not seated."""
337
+ import pacing
338
+ out = {}
339
+ for a in aliases:
340
+ try:
341
+ h = pacing.held(a, route.connection_of(a), est_tokens)
342
+ except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
343
+ continue
344
+ if h and h[0] > within:
345
+ out[a] = f"held by a rate limit: {h[1]}; room in {pacing._wait_text(h[0])}"
346
+ return out
347
+
348
+
333
349
  def retiring() -> set[str]:
334
350
  """Aliases not to spend an explorer seat on: those on a retiring alert
335
351
  (failing their probes; 2026-10-16 both explorers of a bench/core call were
@@ -407,6 +423,10 @@ class TooLarge(ValueError):
407
423
  """The prompt does not fit a member's context window (HTTP 413)."""
408
424
 
409
425
 
426
+ class AllHeld(LookupError):
427
+ """Every seat is held by a known rate limit (HTTP 429): try again later."""
428
+
429
+
410
430
  def estimate_tokens(text: str) -> int:
411
431
  """Characters / 4 plus 10%: tokenizers differ by family, so this errs high.
412
432
  The windows it is compared with are corrected by learned 'maximum context
@@ -590,6 +610,13 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
590
610
  f" windows) in bench/{name}. Trim it under {min(small.values()):,} tokens,"
591
611
  " or choose a collection whose members have larger windows"
592
612
  " (GET /v1/models lists each collection's context_window).")
613
+ # A MODEL THE BENCH KNOWS WILL BE REFUSED IS NOT ASKED: a member or a
614
+ # `with` whose limits have no room before its seat would give up, named
615
+ # like the others; it would only come back as the refusal.
616
+ held = refused_now([*sitting, *nominated], needed, SEAT_TIMEOUT)
617
+ skipped += [{"seat": "member" if a in sitting else "with", "alias": a, "why": why} for a, why in held.items()]
618
+ sitting = [a for a in sitting if a not in held]
619
+ nominated = [a for a in nominated if a not in held]
593
620
  if pictures and not collection.get("shape"):
594
621
  shape, how = "vision", "the request carries images"
595
622
  elif not n_explorers:
@@ -603,6 +630,8 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
603
630
  # `with` in the request body is route.py's --with: an explorer seat.
604
631
  from run import _registered_names
605
632
  avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
633
+ # and have room now: an explorer seat can go to a model without a wait.
634
+ avoid |= set(refused_now(_registered_names(), needed, 0))
606
635
  if pictures:
607
636
  avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
608
637
  p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
@@ -615,8 +644,9 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
615
644
  + ", and no model that takes images was free for an explorer seat. Add one to the"
616
645
  " collection, or call one whose together.capabilities on GET /v1/models lists vision.")
617
646
  if not seats:
618
- raise LookupError(f"collection {name!r} has no members and no explorer could be seated"
619
- + "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
647
+ raise (AllHeld if held else LookupError)(
648
+ f"collection {name!r} has no members and no explorer could be seated"
649
+ + "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
620
650
  panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
621
651
  model = bench_collections.PREFIX + name
622
652
  task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
@@ -718,6 +748,8 @@ def refusal(exc: Exception) -> tuple[int, str]:
718
748
  return 413, str(exc)
719
749
  if isinstance(exc, ValueError):
720
750
  return 400, str(exc)
751
+ if isinstance(exc, AllHeld):
752
+ return 429, str(exc)
721
753
  if isinstance(exc, LookupError):
722
754
  return 503, str(exc)
723
755
  return 500, f"the panel failed ({type(exc).__name__}: {exc})"
package/launcher.py CHANGED
@@ -22,7 +22,7 @@ ROOT = Path(__file__).resolve().parent # the app: a checkout, th
22
22
  SERVERS = {"serve": "serve.py", "gateway": "gateway.py", "mcp": "mcp_server.py",
23
23
  "init": str(Path("scripts") / "init.py")}
24
24
  # Everything but running a server is cli.py, a client of a running server.
25
- CLIENT = ("status", "ask", "verdict", "models", "model", "candidates", "onboard", "dismiss",
25
+ CLIENT = ("status", "restart", "ask", "verdict", "models", "model", "candidates", "onboard", "dismiss",
26
26
  "collections", "collection", "endpoints", "endpoint", "keys", "key", "settings")
27
27
 
28
28
 
@@ -57,6 +57,7 @@ def usage() -> str:
57
57
  "",
58
58
  "Using it (a client of a running server, BENCHWARMERS_URL; --help on any):",
59
59
  " status what is running, and where",
60
+ " restart [--force] start the dashboard again on the code now on disk",
60
61
  " ask <collection> [prompt] ask a collection; -f FILE adds a file, a pipe reads stdin",
61
62
  " verdict <call> --useful|--not-useful [--real ...] [--false ...]",
62
63
  " models | model <action> list, rename, role, retire, restore, max-tokens, ...",
@@ -1,10 +1,16 @@
1
- """One list of models worth adding, from models.dev and FreeLLMAPI, deduped.
1
+ """One list of models worth adding, from FreeLLMAPI and models.dev, deduped.
2
2
 
3
3
  python metrics/discover.py # summary: free models by reach, new this week
4
4
 
5
5
  The shopping list: Discover's Add (serve.py) puts a model on the bench by one
6
6
  of the routes below. A model offered by the same provider in both sources
7
- appears once, carrying both sources and the better-filled fields.
7
+ appears once, carrying both sources. FreeLLMAPI leads where it lists the
8
+ model (its name, its provider's name, context, tools, limits, its smartest
9
+ and fastest ranks, size, monthly budget, quirks and freellmapi.co page);
10
+ models.dev fills the rest, and alone has the paid models and prices, API base
11
+ URLs (reach, direct routes), modalities, release and knowledge dates.
12
+ Image and audio generation in FreeLLMAPI's list are left out: the bench asks
13
+ for text.
8
14
 
9
15
  FREE
10
16
  ----
@@ -149,7 +155,7 @@ def hub_routes(rows: list[dict]) -> tuple[list[list[dict]], list[str]]:
149
155
  mine.append({"via": "hub", "hub": which, "connection": conn, "endpoint": ep,
150
156
  "label": f"{r['provider_name']} via {label} ({conn} connection)",
151
157
  "note": f"Also serves it to everything else that uses {label}.",
152
- "steps": ["; ".join(said), "The bench: register, a 4-call probe, then the suite"],
158
+ "steps": ["; ".join(said), "The bench: register it and onboard it as chosen below"],
153
159
  "on_bench_as": next((a for e, _c, _n, a, s in bench if e == ep and s == served), None)})
154
160
  out.append(mine)
155
161
  return out, errors
@@ -158,6 +164,8 @@ def hub_routes(rows: list[dict]) -> tuple[list[list[dict]], list[str]]:
158
164
  def build() -> list[dict]:
159
165
  import endpoints
160
166
  md, fl = sources.cached("modelsdev"), sources.cached("freellmapi")
167
+ pages = set(sources.cached("freellmapi-pages").get("slugs") or [])
168
+ platform_names = {p.get("id"): p.get("name") for p in fl.get("platforms") or []}
161
169
  md_conn = {v: k for k, v in sources.connection_to_modelsdev().items()}
162
170
  bench = _on_bench()
163
171
  # Open WebUI's connections, as far as the bench can tell without asking it.
@@ -185,31 +193,37 @@ def build() -> list[dict]:
185
193
  "vision": "image" in ((m.get("modalities") or {}).get("input") or []),
186
194
  "released": m.get("release_date"), "knowledge": m.get("knowledge"),
187
195
  "open_weights": m.get("open_weights"), "api": p.get("api"),
188
- "openai_like": openai_like, "connection": md_conn.get(prov), "limits": None}
196
+ "openai_like": openai_like, "connection": md_conn.get(prov), "limits": None,
197
+ "rank": None, "speed": None, "size": None, "budget": None, "quirks": [], "page": None}
189
198
 
190
199
  for m in fl.get("models") or []:
200
+ if (m.get("modality") or "chat") != "chat":
201
+ continue # image or audio generation
191
202
  plat = m.get("platform", "")
192
203
  prov = sources.FREELLMAPI_TO_MODELSDEV.get(plat, plat)
193
204
  key = (prov, sources.norm_model(m.get("modelId", "")))
194
- lim = {k: v for k, v in (m.get("limits") or {}).items() if v} or None
205
+ lead = {"sources": ["freellmapi"], "free": True, # listed in a free-tier catalog
206
+ "name": sources.freellmapi_name(m.get("displayName") or "") or m.get("modelId"),
207
+ "provider_name": platform_names.get(plat) or prov,
208
+ "limits": {k: v for k, v in (m.get("limits") or {}).items() if v} or None,
209
+ "rank": m.get("intelligenceRank"), "speed": m.get("speedRank"),
210
+ "size": m.get("sizeLabel") or None, "budget": m.get("monthlyTokenBudget") or None,
211
+ "quirks": [{k: q.get(k) for k in ("title", "body", "severity")} for q in m.get("quirks") or []],
212
+ "page": sources.freellmapi_page(m.get("displayName") or "", pages)}
195
213
  row = rows.get(key)
196
214
  if row:
197
- row["sources"].append("freellmapi")
198
- row["free"] = True # listed in a free-tier catalog
199
- row["limits"] = lim
200
- row["context"] = row["context"] or m.get("contextWindow")
201
- row["tools"] = row["tools"] if row["tools"] is not None else m.get("supportsTools")
215
+ row.update(lead, sources=list(dict.fromkeys(["freellmapi", *row["sources"]])),
216
+ context=m.get("contextWindow") or row["context"], # the free tier's, where it says
217
+ tools=row["tools"] if m.get("supportsTools") is None else m.get("supportsTools"))
202
218
  else:
203
219
  rows[key] = {
204
- "in_mods": ["text", "image"] if m.get("supportsVision") else ["text"], "out_mods": ["text"],
205
- "provider": prov, "provider_name": prov, "model": m.get("modelId"),
206
- "name": m.get("displayName") or m.get("modelId"), "sources": ["freellmapi"],
207
- "free": True, "cost": None, "context": m.get("contextWindow"), "output": None,
220
+ **lead, "in_mods": ["text", "image"] if m.get("supportsVision") else ["text"], "out_mods": ["text"],
221
+ "provider": prov, "model": m.get("modelId"),
222
+ "cost": None, "context": m.get("contextWindow"), "output": None,
208
223
  "tools": m.get("supportsTools"), "reasoning": None,
209
224
  "vision": m.get("supportsVision"), "released": None, "knowledge": None,
210
225
  "open_weights": None, "api": None,
211
- "openai_like": False, "connection": FREELLMAPI_TO_CONNECTION.get(plat),
212
- "limits": lim}
226
+ "openai_like": False, "connection": FREELLMAPI_TO_CONNECTION.get(plat)}
213
227
 
214
228
  # The same model through ANOTHER provider is fine to add, but worth knowing.
215
229
  by_model: dict[str, list[str]] = {}
@@ -245,7 +259,11 @@ def build() -> list[dict]:
245
259
  out.append(r)
246
260
  _write_seen(seen)
247
261
  order = {"connected": 0, "easy to add": 1, "on bench": 2, "other": 3}
248
- out.sort(key=lambda r: (not r["free"], not r["new"], order[r["reach"]], r["provider"], r["model"]))
262
+ # Within each reach, FreeLLMAPI's smartest first; the models it does not rank
263
+ # after, new ones first. Not new first overall: models.dev adds hundreds a
264
+ # week, which buried the ranked ones (the badge still marks them).
265
+ out.sort(key=lambda r: (not r["free"], order[r["reach"]], r["rank"] is None, r["rank"] or 0, not r["new"],
266
+ r["provider"], r["model"]))
249
267
  return out
250
268
 
251
269
 
@@ -293,6 +311,38 @@ def _selftest() -> None:
293
311
  over = sources.free_in_own_client_only({"opencode": False, "x": True})
294
312
  assert over["opencode"] is False and over["x"] is True and over["opencode-go"] is True, over
295
313
  assert sources.free_in_own_client_only(True) == sources.free_in_own_client_only({}) # not a map: ignored
314
+ # freellmapi.co names a page by the display name without its note; only its sitemap's pages exist.
315
+ assert sources.freellmapi_page("Llama 4 Scout (CF)", {"llama-4-scout"}) == "https://freellmapi.co/model/llama-4-scout"
316
+ assert sources.freellmapi_page("GLM-4.7 FlashX", {"llama-4-scout"}) is None
317
+ # FreeLLMAPI leads where it lists a model, models.dev fills the rest; image models out; smartest first.
318
+ md = {"acme": {"name": "Acme", "api": "https://acme.example/v1", "models": {
319
+ "acme/big": {"name": "Big", "cost": {"input": 1, "output": 2}, "limit": {"context": 8000},
320
+ "modalities": {"input": ["text", "image"], "output": ["text"]}, "release_date": "2026-01-01"},
321
+ "acme/small": {"name": "Small", "cost": {"input": 0, "output": 0}},
322
+ "acme/tiny": {"name": "Tiny", "cost": {"input": 0, "output": 0}}}}}
323
+ fl = {"platforms": [{"id": "acme", "name": "Acme Cloud"}], "models": [
324
+ {"platform": "acme", "modelId": "acme/big", "displayName": "Big One (Acme)", "intelligenceRank": 3,
325
+ "speedRank": 9, "sizeLabel": "Large", "monthlyTokenBudget": "~3M", "contextWindow": 4000,
326
+ "limits": {"rpm": 10, "rpd": None}, "quirks": [{"slug": "q", "title": "T", "body": "B", "severity": "info"}]},
327
+ {"platform": "acme", "modelId": "acme/painter", "displayName": "Painter", "modality": "image"},
328
+ {"platform": "acme", "modelId": "acme/tiny", "displayName": "Tiny", "intelligenceRank": 1}]}
329
+ feeds = {"modelsdev": md, "freellmapi": fl, "freellmapi-pages": {"slugs": ["big-one"]}}
330
+ import tempfile
331
+ kept = sources.cached, globals()["_on_bench"], globals()["SEEN"]
332
+ try:
333
+ sources.cached = lambda name: feeds.get(name, {})
334
+ globals().update(_on_bench=lambda: [], SEEN=pathlib.Path(tempfile.mkdtemp()) / "seen.json")
335
+ rows = [r for r in build() if r["provider"] == "acme"]
336
+ finally:
337
+ sources.cached = kept[0]
338
+ globals().update(_on_bench=kept[1], SEEN=kept[2])
339
+ big = next(r for r in rows if r["model"] == "acme/big")
340
+ assert (big["sources"], big["name"], big["provider_name"]) == (["freellmapi", "models.dev"], "Big One", "Acme Cloud"), big
341
+ assert big["free"] and big["context"] == 4000 and big["limits"] == {"rpm": 10} and big["released"] == "2026-01-01", big
342
+ assert (big["rank"], big["speed"], big["size"], big["budget"]) == (3, 9, "Large", "~3M"), big
343
+ assert big["page"] == "https://freellmapi.co/model/big-one" and big["quirks"] == [
344
+ {"title": "T", "body": "B", "severity": "info"}] and big["vision"], big
345
+ assert [r["model"] for r in rows] == ["acme/tiny", "acme/big", "acme/small"], [r["model"] for r in rows]
296
346
  print("discover selftest ok")
297
347
 
298
348
 
@@ -306,7 +356,7 @@ def main() -> None:
306
356
  by = {}
307
357
  for r in free:
308
358
  by[r["reach"]] = by.get(r["reach"], 0) + 1
309
- print(f"{len(rows)} models ({len(free)} free) from models.dev + FreeLLMAPI;"
359
+ print(f"{len(rows)} models ({len(free)} free) from FreeLLMAPI + models.dev;"
310
360
  f" free by reach: {by}; new this week: {sum(r['new'] for r in rows)}")
311
361
  both = sum(1 for r in rows if len(r["sources"]) > 1)
312
362
  print(f"{both} listed by both sources (shown once)")
package/metrics/pacing.py CHANGED
@@ -177,6 +177,28 @@ def _ago(s: float) -> str:
177
177
  return "just now" if s < 60 else f"{s / 60:.0f} min ago" if s < 7200 else f"{s / 3600:.0f} h ago"
178
178
 
179
179
 
180
+ def _since(rs: list[dict], records: list[dict]) -> float:
181
+ """The oldest ledger line any of these windows or quota records counts."""
182
+ spans = [AXES[r["axis"]][1] for r in rs]
183
+ return min([ledger.month_start(time.time()) if s == "month" else time.time() - s for s in spans]
184
+ + [_ts(q["observed_at"]) for q in records if q.get("observed_at")])
185
+
186
+
187
+ def held(alias: str, connection: str, est_tokens: int = 1) -> tuple[float, str] | None:
188
+ """(seconds until there is room, why) when a call to `alias` would have to
189
+ wait or be refused now, else None. Read only: no slot is taken. For
190
+ picking seats, so a model the bench knows will be refused is not seated
191
+ (gateway.refused_now); `admit` still decides each call."""
192
+ import quota
193
+ rs, records = rules(alias, connection), quota.live(connection, alias)
194
+ if not rs and not records:
195
+ return None
196
+ now = time.time()
197
+ block = _blocked(rs, records, alias, connection, est_tokens, now,
198
+ list(ledger.rows(_since(rs, records))), _inflight(now))
199
+ return None if block is None else (max(block[0] - now, 0.0), block[1])
200
+
201
+
180
202
  def admit(alias: str, connection: str, est_tokens: int, left: float, sleep=time.sleep) -> tuple[str | None, str]:
181
203
  """Wait for room, or refuse. Returns (ticket, refusal): a ticket to pass
182
204
  to `release` once the call's ledger line is written (None when no limit
@@ -189,9 +211,7 @@ def admit(alias: str, connection: str, est_tokens: int, left: float, sleep=time.
189
211
  records = quota.live(connection, alias)
190
212
  if not rs and not records:
191
213
  return None, ""
192
- spans = [AXES[r["axis"]][1] for r in rs]
193
- since = min([ledger.month_start(time.time()) if s == "month" else time.time() - s for s in spans]
194
- + [_ts(q["observed_at"]) for q in records if q.get("observed_at")])
214
+ since = _since(rs, records)
195
215
  with locked.lock(LOCK):
196
216
  now = time.time()
197
217
  rows = list(ledger.rows(since))