@wendellresearch-ai/benchwarmers 0.3.3 → 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.
Files changed (50) hide show
  1. package/GATEWAY.md +9 -5
  2. package/PRIVACY.md +9 -3
  3. package/README.md +31 -8
  4. package/USAGE.md +81 -15
  5. package/VERSION +1 -1
  6. package/cli.py +17 -5
  7. package/gateway.py +34 -2
  8. package/launcher.py +2 -1
  9. package/metrics/aliases.py +24 -3
  10. package/metrics/discover.py +68 -18
  11. package/metrics/endpoints.py +36 -4
  12. package/metrics/model_roles.py +3 -1
  13. package/metrics/pacing.py +23 -3
  14. package/metrics/quotaview.py +460 -0
  15. package/metrics/route.py +3 -2
  16. package/metrics/scheduler.py +2 -1
  17. package/metrics/sources.py +29 -6
  18. package/metrics/wrong.py +3 -1
  19. package/package.json +1 -1
  20. package/scripts/check.py +3 -0
  21. package/scripts/onboard.py +12 -1
  22. package/scripts/test_hubs.py +10 -5
  23. package/scripts/test_no_owui.py +14 -2
  24. package/scripts/test_quota.py +12 -2
  25. package/scripts/test_serve_edit.py +58 -5
  26. package/scripts/test_server_json.py +22 -2
  27. package/serve.py +570 -128
  28. package/suite/README.md +33 -0
  29. package/suite/pace.py +8 -4
  30. package/suite/run.py +144 -16
  31. package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
  32. package/suite/tasks/vision-bars/task.json +40 -0
  33. package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
  34. package/suite/tasks/vision-invoice/task.json +40 -0
  35. package/suite/tasks/vision-label/fixtures/label.png +0 -0
  36. package/suite/tasks/vision-label/task.json +52 -0
  37. package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
  38. package/suite/tasks/vision-receipt/task.json +44 -0
  39. package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
  40. package/suite/tasks/vision-screen/task.json +48 -0
  41. package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
  42. package/suite/tasks/vision-shapes/task.json +40 -0
  43. package/suite/tasks/vision-table/fixtures/table.png +0 -0
  44. package/suite/tasks/vision-table/task.json +40 -0
  45. package/suite/test_vision_tasks.py +201 -0
  46. package/suite/vision_keys.py +74 -0
  47. package/suite/vision_sample.py +232 -0
  48. package/suite/vision_tasks.py +338 -0
  49. package/ui/bench.css +71 -6
  50. package/ui/bench.js +543 -49
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
@@ -11,7 +11,13 @@
11
11
  <a href="https://pypi.org/project/benchwarmers/"><img src="https://img.shields.io/pypi/v/benchwarmers?color=3775a9&logo=pypi&logoColor=white" alt="PyPI"></a>
12
12
  <a href="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml"><img src="https://img.shields.io/github/actions/workflow/status/WendellResearch-AI/benchwarmers/check.yml?branch=main&logo=github&label=CI%20tests" alt="CI tests"></a>
13
13
  <a href="https://registry.modelcontextprotocol.io/?q=benchwarmers"><img src="https://img.shields.io/badge/MCP_Registry-111111?logo=modelcontextprotocol&logoColor=white" alt="In the official MCP Registry"></a>
14
+ <a href="https://smithery.ai/skills/wendellresearch-ai/benchwarmers"><img src="https://img.shields.io/badge/Smithery-FF5601?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxODggMjExIiBmaWxsPSIjZmZmIj48cGF0aCBkPSJNNDEuNzAxMSA3Ny40OEgwVjEwMC4yNjRDMCAxMTguMTcxIDE0LjUyNzUgMTMyLjY5OCAzMi40MzQyIDEzMi42OThINTUuMjE4M1Y5MC45OTcxQzU1LjIxODMgODMuNTQxOCA0OS4xNTY1IDc3LjQ4IDQxLjcwMTEgNzcuNDhaIi8%2BPHBhdGggZD0iTTY2LjA4NzkgOTAuOTk3MVYxMzIuNjk4SDEwNy43ODlDMTE1LjI0NCAxMzIuNjk4IDEyMS4zMDYgMTI2LjYzNiAxMjEuMzA2IDExOS4xODFWNzcuNDhINzkuNjA1MUM3Mi4xNDk3IDc3LjQ4IDY2LjA4NzkgODMuNTQxOCA2Ni4wODc5IDkwLjk5NzFaIi8%2BPHBhdGggZD0iTTE1NC45OTUgNzcuNDhIMTMyLjIxVjExOS4xODFDMTMyLjIxIDEyNi42MzYgMTM4LjI3MiAxMzIuNjk4IDE0NS43MjggMTMyLjY5OEgxODcuNDI5VjEwNi4xNTJDMTg3LjQyOSA4OC4yNDQ5IDE3Mi45MDEgNzcuNTE0OCAxNTQuOTk1IDc3LjUxNDhWNzcuNDhaIi8%2BPHBhdGggZD0iTTAgNDUuOTUxNFY2Ni42MTAzSDQxLjcwMTFDNDkuMTU2NSA2Ni42MTAzIDU1LjIxODMgNjAuNTQ4NSA1NS4yMTgzIDUzLjA5MzJWMEMyNS44NDk4IDAuMjA5MDI4IDAuMDM0ODM4IDE0LjY2NjggMCA0NS45NTE0WiIvPjxwYXRoIGQ9Ik02Ni4wODc5IDUzLjA5M0M2Ni4wODc5IDYwLjU0ODMgNzIuMTQ5NyA2Ni42MTAxIDc5LjYwNTEgNjYuNjEwMUgxMjEuMzA2VjExLjQ2MTVDMTAwLjkyNiA4LjM2MDkyIDkwLjMwMDMgMS43NDE2OSA2Ni4wODc5IDAuMjQzNjUyVjUzLjA1ODFWNTMuMDkzWiIvPjxwYXRoIGQ9Ik0xNDQuMDkgMTMuMDYzOUMxMzkuNzcgMTMuMDYzOSAxMzUuODM0IDEyLjkyNDYgMTMyLjIxIDEyLjY4MDdWNjYuNjFIMTU0Ljk5NUMxNzIuOTAxIDY2LjYxIDE4Ny40MjkgNTIuMDgyNSAxODcuNDI5IDM0LjE3NThWMC4xNzM4MjhDMTc3LjI5MSA4LjExNjkgMTYyLjkwMyAxMy4wNjM5IDE0NC4wOSAxMy4wNjM5WiIvPjxwYXRoIGQ9Ik0wIDE3Ni4wMDJWMjEwLjAwNEMxMC4xMzc5IDIwMi4wNjEgMjQuNTI2IDE5Ny4xMTQgNDMuMzM4NSAxOTcuMTE0QzQ3LjY1ODQgMTk3LjExNCA1MS41OTUxIDE5Ny4yNTMgNTUuMjE4MyAxOTcuNTMyVjE0My42MDNIMzIuNDM0MkMxNC41Mjc1IDE0My42MDMgMCAxNTguMTMgMCAxNzYuMDM3TDAgMTc2LjAwMloiLz48cGF0aCBkPSJNMTA3Ljc4OSAxNDMuNjAzSDY2LjA4NzlWMTk4LjcxNkM4Ni40NjgxIDIwMS44MTcgOTcuMDkzOCAyMDguNDM2IDEyMS4zMDYgMjA5LjkzNFYxNTcuMTJDMTIxLjMwNiAxNDkuNjY0IDExNS4yNDQgMTQzLjYwMyAxMDcuNzg5IDE0My42MDNaIi8%2BPHBhdGggZD0iTTEzMi4yMSAxNTcuMDg1VjIxMC4xNzhDMTYxLjU0NCAyMDkuOTY5IDE4Ny4zMjQgMTk1LjUxMSAxODcuNDI5IDE2NC4yOTZWMTQzLjU2OEgxNDUuNzI4QzEzOC4yNzIgMTQzLjU2OCAxMzIuMjEgMTQ5LjYzIDEzMi4yMSAxNTcuMDg1WiIvPjwvc3ZnPg%3D%3D" alt="The benchwarmers skill on Smithery"></a>
15
+ <a href="https://skills.sh/wendellresearch-ai/benchwarmers/benchwarmers"><img src="https://img.shields.io/badge/skills.sh-111111" alt="On skills.sh"></a>
16
+ <a href="https://glama.ai/mcp/servers/WendellResearch-AI/benchwarmers"><img src="https://glama.ai/mcp/servers/WendellResearch-AI/benchwarmers/badges/score.svg" alt="Benchwarmers MCP server, quality and maintenance score on Glama"></a>
17
+ <a href="https://cursor.directory/plugins/benchwarmers"><img src="https://img.shields.io/badge/cursor.directory-111111?logo=cursor&logoColor=white" alt="On cursor.directory"></a>
18
+ <a href="#from-claude-code-and-other-agents"><img src="https://img.shields.io/badge/Antigravity-plugin-3186FF?labelColor=111111&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjEyIDE3IDg2IDgwIj48cGF0aCBkPSJNODkuNyA5My43YzQuNjcgMy41IDExLjY3IDEuMTcgNS4yNS01LjI1Qzc1LjcgNjkuNzggNzkuNzggMTguNDUgNTUuODcgMTguNDVTMzYuMDMgNjkuNzggMTYuNzggODguNDVjLTcgNyAuNTggOC43NSA1LjI1IDUuMjVDNDAuMTIgODEuNDUgMzguOTUgNTkuODYgNTUuODcgNTkuODZTNzEuNjIgODEuNDUgODkuNyA5My43WiIgZmlsbD0iIzMxODZGRiIvPjxtYXNrIGlkPSJtIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4PSIxMyIgeT0iMTgiIHdpZHRoPSI4NSIgaGVpZ2h0PSI3OCI%2BPHBhdGggZD0iTTg5LjcgOTMuN2M0LjY3IDMuNSAxMS42NyAxLjE3IDUuMjUtNS4yNUM3NS43IDY5Ljc4IDc5Ljc4IDE4LjQ1IDU1Ljg3IDE4LjQ1UzM2LjAzIDY5Ljc4IDE2Ljc4IDg4LjQ1Yy03IDcgLjU4IDguNzUgNS4yNSA1LjI1QzQwLjEyIDgxLjQ1IDM4Ljk1IDU5Ljg2IDU1Ljg3IDU5Ljg2UzcxLjYyIDgxLjQ1IDg5LjcgOTMuN1oiIGZpbGw9IiNmZmYiLz48L21hc2s%2BPGcgbWFzaz0idXJsKCNtKSI%2BPGcgZmlsdGVyPSJ1cmwoI2EpIj48ZWxsaXBzZSBjeD0iOTYuNDkiIGN5PSIzNS4xMiIgcng9IjI5LjUiIHJ5PSIzMC4xNSIgdHJhbnNmb3JtPSJyb3RhdGUoNzYuOTIgOTYuNDkgMzUuMTIpIiBmaWxsPSIjRkM0MTNEIi8%2BPC9nPjxnIGZpbHRlcj0idXJsKCNiKSI%2BPGVsbGlwc2UgY3g9IjkuMDMiIGN5PSI0MS42NiIgcng9IjMwLjgzIiByeT0iMzkuOTQiIHRyYW5zZm9ybT0icm90YXRlKDc0LjEzIDkuMDMgNDEuNjYpIiBmaWxsPSIjMDBCOTVDIi8%2BPC9nPjxnIGZpbHRlcj0idXJsKCNjKSI%2BPGVsbGlwc2UgY3g9Ijc1Ljc1IiBjeT0iMTA0LjgyIiByeD0iMjkuMDIiIHJ5PSIyNy45NCIgdHJhbnNmb3JtPSJyb3RhdGUoNzYuOTIgNzUuNzUgMTA0LjgyKSIgZmlsbD0iIzMxODZGRiIvPjwvZz48ZyBmaWx0ZXI9InVybCgjZCkiPjxlbGxpcHNlIGN4PSIzMy41NyIgY3k9IjM1LjQiIHJ4PSIzMy41NyIgcnk9IjM1LjQiIHRyYW5zZm9ybT0ibWF0cml4KC0uNDEgLjkxMiAtLjkxMiAtLjQxIDEwMS4yNSAtMTUuMTcpIiBmaWxsPSIjRkJCQzA0Ii8%2BPC9nPjwvZz48ZGVmcz48ZmlsdGVyIGlkPSJhIiB4PSIyOCIgeT0iLTMzIiB3aWR0aD0iMTM3IiBoZWlnaHQ9IjEzNiIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSIxOC44Ii8%2BPC9maWx0ZXI%2BPGZpbHRlciBpZD0iYiIgeD0iLTYzIiB5PSItMjMiIHdpZHRoPSIxNDQiIGhlaWdodD0iMTI5IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiPjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjE2Ii8%2BPC9maWx0ZXI%2BPGZpbHRlciBpZD0iYyIgeD0iMTYiIHk9IjQ0IiB3aWR0aD0iMTE5IiBoZWlnaHQ9IjEyMSIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSIxNS4yIi8%2BPC9maWx0ZXI%2BPGZpbHRlciBpZD0iZCIgeD0iLTgiIHk9Ii02MSIgd2lkdGg9IjEyNyIgaGVpZ2h0PSIxMjQiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSI%2BPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMTMuOCIvPjwvZmlsdGVyPjwvZGVmcz48L3N2Zz4%3D" alt="Antigravity plugin"></a>
14
19
  <a href="#docker-and-hosting"><img src="https://img.shields.io/badge/Docker-compose-2496ED?logo=docker&logoColor=white" alt="Runs in Docker"></a>
20
+ <a href="https://render.com/deploy?repo=https://github.com/WendellResearch-AI/benchwarmers"><img src="https://img.shields.io/badge/Render-deploy-8A05FF?logo=render&logoColor=white" alt="Deploy to Render in one click"></a>
15
21
  <br>
16
22
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-OpenAI_%7C_Responses-111111" alt="OpenAI chat completions and Responses API"></a>
17
23
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Anthropic_Messages-111111" alt="Anthropic Messages API"></a>
@@ -154,8 +160,11 @@ dependencies either way: the app is the Python standard library.
154
160
  4. **Onboard a few models** from it ("Onboard a model"): tick one or several;
155
161
  each is registered, probed and run through the suite, one at a time, with
156
162
  the progress beside the button. **Quick** probes now and leaves the suite
157
- to the scheduled passes; **Quick + ad hoc** also keeps the bench from ever
158
- calling the model on its own, for a metered or trial key.
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
166
+ model** for one that reads images and little else: it sits only for
167
+ questions about images, and is tested on the vision tasks, not the code ones.
159
168
 
160
169
  5. **Make a collection** on the Collections page (the models a panel always
161
170
  includes, and what it is for). The dashboard answers the gateway's routes on
@@ -186,10 +195,12 @@ dependencies either way: the app is the Python standard library.
186
195
  (requests a minute, day or month; tokens a minute or day). They are kept per
187
196
  connection, since models behind one key share them, and enforced: a call
188
197
  waits when the wait fits, or is refused before it goes out. Every call lands
189
- in a monthly usage ledger with its tokens, and the Quotas page shows what is
190
- left and when it resets.
191
- - **Discover.** models.dev and FreeLLMAPI, merged into one list of models
192
- 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.
193
204
 
194
205
  ## Asking a panel
195
206
 
@@ -240,6 +251,7 @@ without it: ask and judge, and run the bench itself. It is a client of a
240
251
  running server, so the same commands work against one on another machine.
241
252
 
242
253
  bench status what is running, and where
254
+ bench restart start it again on the code now on disk
243
255
  bench collections what you can ask
244
256
  bench ask core "Where can this return a stale value?" -f cache.py
245
257
  git diff | bench ask core "Review this diff" a pipe is the prompt
@@ -309,6 +321,13 @@ Benchwarmers running, the hook shows a hook-error notice at the end of each
309
321
  turn, as `/mcp` shows the server as failed; start the server, or disable the
310
322
  plugin for that session.
311
323
 
324
+ **Cursor and Antigravity plugins.** The same skill, with the stdio server
325
+ below (so they need Node and Python 3.11 or later): Cursor's is
326
+ [on cursor.directory](https://cursor.directory/plugins/benchwarmers), and
327
+ Antigravity's installs from the repository:
328
+
329
+ agy plugin install https://github.com/WendellResearch-AI/benchwarmers
330
+
312
331
  **Any other MCP client** that speaks HTTP:
313
332
 
314
333
  ```json
@@ -417,9 +436,13 @@ reaches Wendell Research. What it sends, and where:
417
436
  a hub such as Open WebUI, or a local runtime. That includes the probes, the
418
437
  suite and onboarding, which spend calls on your keys. Add only endpoints you
419
438
  are content to send that work to.
420
- - **Catalog reads**, no key and nothing of yours: models.dev and FreeLLMAPI
421
- 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.
422
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.
423
446
  - **Charts**: the dashboard's browser page loads Observable Plot from jsDelivr
424
447
  at a pinned version.
425
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,20 +52,31 @@ 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.
59
-
60
- `bench onboard <served> --quick [--ad-hoc]` does the same from a terminal.
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.
71
+
72
+ **Vision model**, the box beside it, gives the model role **vision** as it is
73
+ registered: it sits only for requests that carry images, and its suite is the
74
+ vision tasks (photos of documents, charts, labels and screens with an answer
75
+ key) rather than the text and code ones. Full runs them now, Quick through the
76
+ scheduled passes. For a model that reads images and little else; a model that
77
+ does everything and also takes images stays general and gets both.
78
+
79
+ `bench onboard <served> [--quick] [--ad-hoc] [--vision]` does the same from a terminal.
61
80
  Onboarding takes minutes and spends calls, so models run **one at a time**,
62
81
  in a queue the dashboard keeps:
63
82
 
@@ -84,6 +103,15 @@ everything else well stays `general`.
84
103
  A collection is a named panel: the models it always includes, the shape of
85
104
  work it is for (review, design, enumeration, ...), and how its reply comes
86
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
+
87
115
  Each call also seats explorers, the models unused longest, up to the gateway's
88
116
  limits (Gateway settings, on the same page: how many calls a request may make,
89
117
  and how many of them explore).
@@ -184,17 +212,55 @@ are often wrong together.
184
212
 
185
213
  ## Discover: finding models to add
186
214
 
187
- **Discover** merges models.dev and FreeLLMAPI into one list, free models
188
- first, with what is new this week badged. A row opens its popup: what the
189
- model takes and does, its price, and every route this install has to it, each
190
- 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
191
223
  API) puts it in that hub too; adding direct registers it on its own endpoint
192
- 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.
193
227
 
194
228
  Some providers' free models work only inside their own app; Discover leaves
195
229
  those out and keeps their paid models. Settings, **Free only in their own
196
230
  app**, switches a provider either way or adds one.
197
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
+
198
264
  ## The scheduler
199
265
 
200
266
  The recurring jobs (the morning availability probe, the catalog refresh, the
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.3
1
+ 0.5.0
package/cli.py CHANGED
@@ -258,10 +258,9 @@ 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"
264
- items = [{"served": s, "endpoint": args.endpoint or "", "mode": mode} for s in args.served]
261
+ mode = ("quick" if args.quick else "full") + ("-adhoc" if args.ad_hoc else "")
262
+ items = [{"served": s, "endpoint": args.endpoint or "", "mode": mode, "vision": args.vision}
263
+ for s in args.served]
265
264
  reply = call("POST", "/api/onboard-queue", {"items": items})
266
265
  if args.wait:
267
266
  while reply.get("running"):
@@ -384,6 +383,13 @@ def cmd_verdict(args) -> None:
384
383
  f" {len(body['real'])} real, {len(body['false'])} false"))
385
384
 
386
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
+
387
393
  def cmd_endpoints(args) -> None:
388
394
  reply = call("GET", "/api/endpoints")
389
395
  out(args, reply, lambda r: table(r["endpoints"], ["name", "base", "kind", "hub", "default", "key_set", "models"],
@@ -546,6 +552,9 @@ def parser() -> argparse.ArgumentParser:
546
552
  return act
547
553
 
548
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)")
549
558
 
550
559
  s = cmd("ask", cmd_ask, "ask a collection: every seat answers")
551
560
  s.add_argument("collection", help="a collection, e.g. core (bench collections)")
@@ -597,7 +606,10 @@ def parser() -> argparse.ArgumentParser:
597
606
  s.add_argument("--quick", action="store_true",
598
607
  help="probe only; the scheduled suite passes fill the suite in later")
599
608
  s.add_argument("--ad-hoc", action="store_true",
600
- 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")
611
+ s.add_argument("--vision", action="store_true",
612
+ help="role vision: a seat only for questions about images, and its suite is the vision tasks")
601
613
 
602
614
  s = cmd("dismiss", cmd_dismiss, "stop offering served models as candidates")
603
615
  s.add_argument("served", nargs="+"), s.add_argument("--endpoint"), s.add_argument("--undo", action="store_true")
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, ...",
@@ -16,17 +16,38 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import pathlib
19
+ import time
19
20
 
20
21
  ROOT = pathlib.Path(__file__).resolve().parent.parent
21
22
  from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
22
23
  HISTORY = DATA / "config" / "alias-history.json"
23
24
 
24
25
 
26
+ _cache: tuple = (None, {}, 0.0)
27
+
28
+
25
29
  def history() -> dict[str, str]:
30
+ """The file, read again only when it changes, and looked at no more than
31
+ once a second: one dashboard render asks tens of thousands of times, and
32
+ even a stat each time was seconds on Windows. A rename written here
33
+ (serve.rename) calls forget(), so it shows at once."""
34
+ global _cache
35
+ seen, now = _cache, time.monotonic() # one read: forget() may swap it on another thread
36
+ if seen[0] and seen[0][0] == str(HISTORY) and now - seen[2] < 1:
37
+ return seen[1]
26
38
  try:
27
- return json.loads(HISTORY.read_text(encoding="utf-8"))
28
- except (OSError, ValueError):
29
- return {}
39
+ st = HISTORY.stat()
40
+ key = (str(HISTORY), st.st_mtime_ns, st.st_size)
41
+ table = seen[1] if seen[0] == key else json.loads(HISTORY.read_text(encoding="utf-8"))
42
+ except (OSError, ValueError): # none yet, or half-written: no renames, for a second
43
+ key, table = (str(HISTORY), None, None), {}
44
+ _cache = (key, table, now)
45
+ return table
46
+
47
+
48
+ def forget() -> None:
49
+ global _cache
50
+ _cache = (None, {}, 0.0)
30
51
 
31
52
 
32
53
  def current(alias: str, table: dict[str, str] | None = None) -> str: