@wendellresearch-ai/benchwarmers 0.4.0 → 0.5.1

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 2 November 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,23 @@ 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. With an NVIDIA connection on the bench, the public file of rate
19
+ limits build.nvidia.com states, once a day and when you press Refresh on its
20
+ quota tile.
18
21
  - **Sign-ins**, only when you choose one: the provider's own sign-in page, in
19
22
  your browser, and the provider's token endpoint.
23
+ - **Quota reads**, only when you press Refresh on Quota management, carrying
24
+ no prompt:
25
+ - OpenRouter's key status, asked with your key.
26
+ - xKiro's usage, asked with your key for it (from its Open WebUI connection
27
+ when no endpoint has one). Its reply names the account's owner; only the
28
+ quota figures are kept.
29
+ - Requesty's usage for the key and its public model list, asked the same
30
+ way; only the count of free-model requests is kept.
31
+ - For CLI Proxy API's sign-ins, through the proxy, the quota endpoint of the
32
+ provider each sign-in belongs to (Google's, ChatGPT's).
20
33
  - **Charts**: the dashboard's page loads Observable Plot from jsDelivr, at a
21
34
  pinned version, in your browser.
22
35
  - **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,14 @@ 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
+ xKiro, Requesty and CLI Proxy API's sign-ins are read live, at no cost to
201
+ quota. Nothing is taken from a provider's docs; NVIDIA's own stated figures
202
+ are read from its site and watched for changes.
203
+ - **Discover.** FreeLLMAPI's free catalog, with models.dev for the details
204
+ and paid models it lacks, merged into one list of models worth adding,
205
+ smartest first, each with the routes this install has to it.
201
206
 
202
207
  ## Asking a panel
203
208
 
@@ -248,6 +253,7 @@ without it: ask and judge, and run the bench itself. It is a client of a
248
253
  running server, so the same commands work against one on another machine.
249
254
 
250
255
  bench status what is running, and where
256
+ bench restart start it again on the code now on disk
251
257
  bench collections what you can ask
252
258
  bench ask core "Where can this return a stale value?" -f cache.py
253
259
  git diff | bench ask core "Review this diff" a pipe is the prompt
@@ -432,9 +438,15 @@ reaches Wendell Research. What it sends, and where:
432
438
  a hub such as Open WebUI, or a local runtime. That includes the probes, the
433
439
  suite and onboarding, which spend calls on your keys. Add only endpoints you
434
440
  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.
441
+ - **Catalog reads**, no key and nothing of yours: FreeLLMAPI (its feed and
442
+ its site's sitemap) and models.dev once a day, and OpenRouter's public
443
+ model list. With an NVIDIA connection, the public file of rate limits
444
+ build.nvidia.com states, once a day and on Refresh.
437
445
  - **Sign-ins**, only when you choose one: the provider's own sign-in page.
446
+ - **Quota reads**, only when you press Refresh on Quota management, with no
447
+ prompt: OpenRouter's key status, and xKiro's and Requesty's usage, each with
448
+ your key for it (from its Open WebUI connection when no endpoint has one). CLI Proxy
449
+ API asks each sign-in's own provider for its quota.
438
450
  - **Charts**: the dashboard's browser page loads Observable Plot from jsDelivr
439
451
  at a pinned version.
440
452
  - **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,18 @@ 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
113
+ search matches a model, provider or served name, and the select beside it
114
+ shows one provider's models; ticked ones always stay in view. A seat's ×
115
+ takes that member off, as unticking it does. The tags grey out the models that
116
+ do not fit them.
117
+
94
118
  Each call also seats explorers, the models unused longest, up to the gateway's
95
119
  limits (Gateway settings, on the same page: how many calls a request may make,
96
120
  and how many of them explore).
@@ -191,17 +215,84 @@ are often wrong together.
191
215
 
192
216
  ## Discover: finding models to add
193
217
 
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
218
+ **Discover** leads with FreeLLMAPI's catalog of free tiers and fills in from
219
+ models.dev, which alone has prices, paid models and providers' base URLs. One
220
+ list: free models first, then by reach, and within each reach FreeLLMAPI's
221
+ smartest first (the **Rank** column), then what it does not rank, what is new
222
+ this week first and badged. A row opens its popup: what
223
+ the model takes and does, its limits and monthly budget, FreeLLMAPI's known
224
+ quirks for it, links to its freellmapi.co page and models.dev, and every route
225
+ this install has to it, each with an **Add via this**. Adding through a hub (Open WebUI, LiteLLM, CLI Proxy
198
226
  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.
227
+ only. Either way the onboarding joins the same queue, with the same
228
+ **Onboard as** and **Vision model** choice as the Onboard dialog, asked in the
229
+ confirm before anything is added.
200
230
 
201
231
  Some providers' free models work only inside their own app; Discover leaves
202
232
  those out and keeps their paid models. Settings, **Free only in their own
203
233
  app**, switches a provider either way or adds one.
204
234
 
235
+ ## Quota management
236
+
237
+ One tile per provider a bench model arrives through. Each draws the
238
+ provider's limits at the level they apply, and names a model only where a
239
+ limit is that model's own:
240
+ - **Windows**: CLI Proxy API's sign-ins have a 5-hour and a weekly limit per
241
+ model group, a Codex sign-in one for the account. Other providers have the
242
+ windows their replies' headers state; a window past its reset is shown full.
243
+ - **Pool**: OpenRouter's free requests a day, shared by its models, with a bar
244
+ per key, against the limit OpenRouter stated in a rate-limit reply.
245
+ - **Caps**: a day or month cap, counted from the bench's own calls: one you set
246
+ on Endpoints (Rate limits per connection), else one the provider states in
247
+ its replies without saying what is left (a Cohere trial key's 1,000 calls a
248
+ month), shown but not enforced until you set it. Calls made elsewhere with
249
+ the same key are not in the count.
250
+ - **Nothing stated**: how the provider's calls went in the last 24 hours, all
251
+ its models together (green answered, red failed).
252
+
253
+ Nothing is taken from a provider's docs, which can change without notice; a
254
+ limit a provider states in a rate-limit error (UnoRouter's "allows 1
255
+ request(s) every 1 min" for a model) is learned and kept to. One exception,
256
+ watched: NVIDIA states no limit per key, so its tile reads the file
257
+ build.nvidia.com's own badge reads (10,000 requests a day, up to 40 a minute,
258
+ the same for every account) daily and on Refresh, and counts the bench's
259
+ requests against it. If NVIDIA changes those figures, Action needed says so
260
+ (Got it dismisses it); if the file moves or changes shape, Action needed says
261
+ that too, and the tile keeps the figures it last read. Each meter leads
262
+ with the percentage left, then what is left of what in the provider's own unit
263
+ ("94% left · 189 of 200 requests · day"), with its reset on the right. Under the name, one block says where the figures come
264
+ from (**From**: the provider's quota endpoint or its replies' headers, exact;
265
+ or a stated limit counted by the bench), your per-minute caps, and anything a
266
+ live read reported. **Models** names the connection and how it is reached
267
+ (each endpoint or Open WebUI connection, with its address), then lists the
268
+ provider's bench models (each opens its popup), whether each is free to call
269
+ now, and its last 24 hours of calls.
270
+
271
+ **Refresh** picks its own way, and hovering it says which:
272
+ - **A free read** on OpenRouter (it asks the key what is left), on xKiro (its
273
+ usage endpoint: free-model tokens left today), on Requesty (its usage
274
+ endpoint: the key's free-model requests today, by Requesty's count; its limit
275
+ only once a rate-limit reply has stated it) and on CLI Proxy API's sign-ins
276
+ (it asks the proxy for each one's quota, as its own console does; this needs
277
+ CLI Proxy API on the Endpoints page as a hub, with its management key).
278
+ xKiro's and Requesty's keys come from their endpoint, else their Open WebUI
279
+ connection.
280
+ - **One 16-token request** where a provider states its limits only in its
281
+ replies' headers, to read them (Groq, Routeway, a Cohere trial key). Every
282
+ ordinary call reads them too, through Open WebUI as well, since the bench
283
+ streams.
284
+ - **The bench's own records** again everywhere else.
285
+
286
+ Tiles sort with the ones that are out first, then the soonest reset. Drag a
287
+ tile, or move it with the arrow keys, and the order is kept; **Sort** brings
288
+ the default order back.
289
+
290
+ Under the tiles are:
291
+ - the next 7 days' resets, and when each resting model is back
292
+ - **What the bench sent**: requests per provider this month and in the last 24
293
+ hours, as a chart with its table
294
+ - the known limits, folded away
295
+
205
296
  ## The scheduler
206
297
 
207
298
  The recurring jobs (the morning availability probe, the catalog refresh, the
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.5.1
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, ...",
@@ -283,6 +283,11 @@ def refresh() -> dict:
283
283
  "refreshed": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
284
284
  "models": models}
285
285
  CATALOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
286
+ try: # NVIDIA's stated limits, watched daily (an alert on a change)
287
+ import quotaview
288
+ quotaview.watch_nvidia()
289
+ except Exception: # noqa: BLE001
290
+ pass
286
291
  return data
287
292
 
288
293
 
@@ -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)")