@wendellresearch-ai/benchwarmers 0.3.2 → 0.4.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 (38) hide show
  1. package/README.md +19 -4
  2. package/USAGE.md +8 -1
  3. package/VERSION +1 -1
  4. package/cli.py +4 -1
  5. package/metrics/aliases.py +24 -3
  6. package/metrics/endpoints.py +36 -4
  7. package/metrics/model_roles.py +3 -1
  8. package/metrics/route.py +3 -2
  9. package/metrics/wrong.py +3 -1
  10. package/package.json +1 -1
  11. package/scripts/check.py +2 -0
  12. package/scripts/onboard.py +12 -1
  13. package/scripts/test_no_owui.py +5 -2
  14. package/scripts/test_serve_edit.py +4 -4
  15. package/scripts/test_server_json.py +22 -2
  16. package/serve.py +286 -29
  17. package/suite/README.md +33 -0
  18. package/suite/pace.py +8 -4
  19. package/suite/run.py +144 -16
  20. package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
  21. package/suite/tasks/vision-bars/task.json +40 -0
  22. package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
  23. package/suite/tasks/vision-invoice/task.json +40 -0
  24. package/suite/tasks/vision-label/fixtures/label.png +0 -0
  25. package/suite/tasks/vision-label/task.json +52 -0
  26. package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
  27. package/suite/tasks/vision-receipt/task.json +44 -0
  28. package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
  29. package/suite/tasks/vision-screen/task.json +48 -0
  30. package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
  31. package/suite/tasks/vision-shapes/task.json +40 -0
  32. package/suite/tasks/vision-table/fixtures/table.png +0 -0
  33. package/suite/tasks/vision-table/task.json +40 -0
  34. package/suite/test_vision_tasks.py +201 -0
  35. package/suite/vision_keys.py +74 -0
  36. package/suite/vision_sample.py +232 -0
  37. package/suite/vision_tasks.py +338 -0
  38. package/ui/bench.js +87 -10
package/README.md CHANGED
@@ -6,18 +6,24 @@
6
6
  </h1>
7
7
 
8
8
  <p align="center">
9
- <a href="https://github.com/WendellResearch-AI/benchwarmers/releases"><img src="https://img.shields.io/github/v/release/WendellResearch-AI/benchwarmers?color=990000" alt="Latest release"></a>
9
+ <a href="https://github.com/WendellResearch-AI/benchwarmers/releases/latest"><img src="https://img.shields.io/github/v/release/WendellResearch-AI/benchwarmers?logo=github&color=181717" alt="Latest release"></a>
10
10
  <a href="https://www.npmjs.com/package/@wendellresearch-ai/benchwarmers"><img src="https://img.shields.io/npm/v/@wendellresearch-ai/benchwarmers?color=cb3837&logo=npm" alt="npm"></a>
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
- <a href="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml"><img src="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml/badge.svg" alt="Checks"></a>
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
+ <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>
13
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>
14
21
  <br>
15
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>
16
23
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Anthropic_Messages-111111" alt="Anthropic Messages API"></a>
17
24
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Gemini-111111" alt="Gemini API"></a>
18
25
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Ollama-111111" alt="Ollama API"></a>
19
26
  <a href="GATEWAY.md#route-mode-agent-clients"><img src="https://img.shields.io/badge/agents-tool_calling-111111" alt="Agent clients with tool calling, through route mode"></a>
20
- <a href="https://registry.modelcontextprotocol.io/v0/servers?search=io.github.WendellResearch-AI/benchwarmers"><img src="https://img.shields.io/badge/MCP-Registry-111111" alt="MCP server in the official MCP Registry"></a>
21
27
  <br>
22
28
  <a href="#try-it"><img src="https://img.shields.io/badge/Python-3.11_%7C_3.12_%7C_3.13-3776AB?logo=python&logoColor=white" alt="Python 3.11 to 3.13"></a>
23
29
  <a href="#try-it"><img src="https://img.shields.io/badge/core-stdlib_only-2ea44f" alt="The core needs only the Python standard library"></a>
@@ -155,7 +161,9 @@ dependencies either way: the app is the Python standard library.
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
163
  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.
164
+ calling the model on its own, for a metered or trial key. Tick **Vision
165
+ model** for one that reads images and little else: it sits only for
166
+ questions about images, and is tested on the vision tasks, not the code ones.
159
167
 
160
168
  5. **Make a collection** on the Collections page (the models a panel always
161
169
  includes, and what it is for). The dashboard answers the gateway's routes on
@@ -309,6 +317,13 @@ Benchwarmers running, the hook shows a hook-error notice at the end of each
309
317
  turn, as `/mcp` shows the server as failed; start the server, or disable the
310
318
  plugin for that session.
311
319
 
320
+ **Cursor and Antigravity plugins.** The same skill, with the stdio server
321
+ below (so they need Node and Python 3.11 or later): Cursor's is
322
+ [on cursor.directory](https://cursor.directory/plugins/benchwarmers), and
323
+ Antigravity's installs from the repository:
324
+
325
+ agy plugin install https://github.com/WendellResearch-AI/benchwarmers
326
+
312
327
  **Any other MCP client** that speaks HTTP:
313
328
 
314
329
  ```json
package/USAGE.md CHANGED
@@ -57,7 +57,14 @@ spends:
57
57
  its own (no explorer seat, no scheduled runs). The one that saves calls, for
58
58
  a metered, capped or trial key. A collection that lists it still calls it.
59
59
 
60
- `bench onboard <served> --quick [--ad-hoc]` does the same from a terminal.
60
+ **Vision model**, the box beside it, gives the model role **vision** as it is
61
+ registered: it sits only for requests that carry images, and its suite is the
62
+ vision tasks (photos of documents, charts, labels and screens with an answer
63
+ key) rather than the text and code ones. Full runs them now, Quick through the
64
+ scheduled passes. For a model that reads images and little else; a model that
65
+ does everything and also takes images stays general and gets both.
66
+
67
+ `bench onboard <served> [--quick [--ad-hoc]] [--vision]` does the same from a terminal.
61
68
  Onboarding takes minutes and spends calls, so models run **one at a time**,
62
69
  in a queue the dashboard keeps:
63
70
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.2
1
+ 0.4.0
package/cli.py CHANGED
@@ -261,7 +261,8 @@ def cmd_onboard(args) -> None:
261
261
  if args.ad_hoc and not args.quick:
262
262
  sys.exit("--ad-hoc goes with --quick: an ad hoc model gets no suite run of its own")
263
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]
264
+ items = [{"served": s, "endpoint": args.endpoint or "", "mode": mode, "vision": args.vision}
265
+ for s in args.served]
265
266
  reply = call("POST", "/api/onboard-queue", {"items": items})
266
267
  if args.wait:
267
268
  while reply.get("running"):
@@ -598,6 +599,8 @@ def parser() -> argparse.ArgumentParser:
598
599
  help="probe only; the scheduled suite passes fill the suite in later")
599
600
  s.add_argument("--ad-hoc", action="store_true",
600
601
  help="with --quick: the bench never calls it on its own (metered, capped or trial keys)")
602
+ s.add_argument("--vision", action="store_true",
603
+ help="role vision: a seat only for questions about images, and its suite is the vision tasks")
601
604
 
602
605
  s = cmd("dismiss", cmd_dismiss, "stop offering served models as candidates")
603
606
  s.add_argument("served", nargs="+"), s.add_argument("--endpoint"), s.add_argument("--undo", action="store_true")
@@ -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:
@@ -154,13 +154,29 @@ def connections() -> dict:
154
154
  return data if isinstance(data, dict) else {}
155
155
 
156
156
 
157
+ _default: tuple = (None, "")
158
+
159
+
157
160
  def default() -> str:
161
+ """The default endpoint's name, read again only when endpoints.json
162
+ changes: snapshot_key asks once per model, hundreds of times a page."""
163
+ global _default
164
+ try:
165
+ st = FILE.stat()
166
+ key = (str(FILE), st.st_mtime_ns, st.st_size)
167
+ except OSError:
168
+ key = None
169
+ seen = _default
170
+ if key and seen[0] == key:
171
+ return seen[1]
158
172
  data = load()
159
173
  if not data:
160
174
  raise RuntimeError(f"no endpoints configured: add one from the dashboard (python serve.py), run"
161
175
  f" scripts/init.py --endpoint NAME BASE_URL, or create {FILE}"
162
176
  ' like {"name": {"base": "https://.../v1", "key": "name", "default": true}}')
163
- return next((n for n, e in data.items() if e.get("default")), next(iter(data)))
177
+ name = next((n for n, e in data.items() if e.get("default")), next(iter(data)))
178
+ _default = (key, name)
179
+ return name
164
180
 
165
181
 
166
182
  def kind(name: str, data: dict | None = None) -> str:
@@ -322,13 +338,29 @@ def endpoint_name(reg: dict, data: dict | None = None) -> str | None:
322
338
  return next((n for n in same if data[n].get("key") == reg.get("api_key_name")), same[0] if same else None)
323
339
 
324
340
 
341
+ _parsed: tuple = (None, {})
342
+
343
+
325
344
  def registrations() -> dict[str, dict]:
326
345
  """alias -> its yaml entry as written by yaml_block: model_name, api_base,
327
- api_key_name and a `headers` dict. Not a YAML parser; the file is ours."""
346
+ api_key_name and a `headers` dict. Not a YAML parser; the file is ours.
347
+
348
+ Parsed again only when the file changes (a dashboard render asks hundreds
349
+ of times); every caller gets its own copies to change."""
350
+ global _parsed
351
+ try:
352
+ st = YAML.stat()
353
+ except OSError: # a fresh install has registered nothing yet
354
+ return {}
355
+ key = (str(YAML), st.st_mtime_ns, st.st_size)
356
+ if _parsed[0] != key:
357
+ _parsed = (key, _parse_registrations())
358
+ return {a: dict(r, headers=dict(r["headers"])) for a, r in _parsed[1].items()}
359
+
360
+
361
+ def _parse_registrations() -> dict[str, dict]:
328
362
  out: dict[str, dict] = {}
329
363
  current = None
330
- if not YAML.exists(): # a fresh install has registered nothing yet
331
- return out
332
364
  for line in YAML.read_text(encoding="utf-8").splitlines():
333
365
  if (m := re.match(r"\s*-\s*model_id:\s*(.+)", line)):
334
366
  current = out.setdefault(m.group(1).strip().strip("\"'"), {"headers": {}})
@@ -20,7 +20,9 @@ roles: general reasoning / do-it-all: eligible for every shape
20
20
  charts), weak at text-only work: admitted by the vision shape
21
21
  alone, and a collection member with this role sits only when
22
22
  the request carries images (gateway.py). A model that reads
23
- images AND does everything else stays general.
23
+ images AND does everything else stays general. `bench onboard
24
+ --vision` (the picker's Vision model box) onboards one this
25
+ way; its suite is the vision tasks alone (suite/run.applies).
24
26
  decision typed answers with probabilities through a Decisions API, not
25
27
  chat; no shape admits them (config/decision-models.json)
26
28
 
package/metrics/route.py CHANGED
@@ -250,8 +250,9 @@ def suite_recall() -> dict[str, tuple[int, int, int]]:
250
250
  # Claim-verification tasks are a true/false verdict, where a guess
251
251
  # scores half: folding them into finding-recall would blur the one
252
252
  # number that picks the primary. They are reported on their own, as
253
- # are seeded and real-bug (rebench) FAULT/NO FAULT tasks.
254
- if spec.get("shape") in ("claim", "seeded", "rebench") or spec.get("draft"):
253
+ # are seeded and real-bug (rebench) FAULT/NO FAULT tasks, and reading
254
+ # images (vision), which is not what a text panel is picked for.
255
+ if spec.get("shape") in ("claim", "seeded", "rebench", "vision") or spec.get("draft"):
255
256
  continue
256
257
  for answer in sorted((task_dir.parent / "output").glob("*.md")):
257
258
  alias = current_alias(answer.stem) # a renamed model keeps its record
package/metrics/wrong.py CHANGED
@@ -84,7 +84,9 @@ def table() -> dict[str, dict]:
84
84
  "hunt": dict.fromkeys(HUNT, 0)})
85
85
  for path in sorted(TASKS.glob("*/task.json")):
86
86
  spec = json.loads(path.read_text(encoding="utf-8"))
87
- if spec.get("draft"):
87
+ # Reading images has its own record (serve.vision_rows): a trap taken
88
+ # there is a picture misread, not code.
89
+ if spec.get("draft") or spec.get("shape") == "vision":
88
90
  continue
89
91
  for answer in sorted((path.parent / "output").glob("*.md")):
90
92
  text = answer.read_text(encoding="utf-8", errors="replace")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
package/scripts/check.py CHANGED
@@ -73,6 +73,8 @@ CHECKS = [
73
73
  ("suite/test_seed.py", [], False),
74
74
  ("suite/test_rebench.py", [], False),
75
75
  ("suite/test_pick.py", [], False),
76
+ ("suite/test_vision_tasks.py", [], False),
77
+ ("suite/vision_keys.py", [], False),
76
78
  ("scripts/test_client.py", [], True), # runs the llm CLI against a fake server
77
79
  ]
78
80
 
@@ -235,6 +235,9 @@ def main() -> None:
235
235
  ap.add_argument("--ad-hoc", action="store_true",
236
236
  help="mark it ad hoc as it is registered: the bench never calls it on its own"
237
237
  " (for a metered, capped or trial key)")
238
+ ap.add_argument("--vision", action="store_true",
239
+ help="role vision as it is registered: it sits only for requests that carry"
240
+ " images, and its suite is the vision tasks (suite/run.applies)")
238
241
  args = ap.parse_args()
239
242
 
240
243
  name = args.served_name
@@ -296,6 +299,12 @@ def main() -> None:
296
299
  lambda roles: roles.__setitem__("ad_hoc", sorted({*roles.get("ad_hoc", []), alias})),
297
300
  ensure_ascii=False)
298
301
  print(" ad hoc: the bench never calls it on its own")
302
+ if args.vision:
303
+ # Before the probe too: a text panel or a suite pass in between would seat it.
304
+ locked.update(DATA / "config" / "model-roles.json",
305
+ lambda roles: roles.setdefault("models", {}).__setitem__(alias, "vision"),
306
+ ensure_ascii=False)
307
+ print(" role vision: a seat only for requests that carry images")
299
308
  run("scripts/sync_config.py", timeout=300)
300
309
  # Open WebUI's tags name the connection behind the hub; without a hub the
301
310
  # endpoint is the connection, and route.connection_of needs no tags.
@@ -359,10 +368,12 @@ def main() -> None:
359
368
  record_issue(alias, None)
360
369
 
361
370
  # 5. the tier that decides routing
371
+ # A vision model's suite is the vision tasks alone (suite/run.applies picks
372
+ # them by its role); a general model that takes images gets both.
362
373
  if args.skip_suite:
363
374
  print(" [5/5] suite skipped by request")
364
375
  else:
365
- print(" [5/5] running the internal suite...")
376
+ print(" [5/5] running the internal suite" + (" (its vision tasks)..." if args.vision else "..."))
366
377
  try:
367
378
  suite = run("suite/run.py", "--model", alias, "--timeout", "300",
368
379
  timeout=3600)
@@ -241,12 +241,15 @@ def owner(fake: str) -> None:
241
241
  OWUI_MODELS.append({"id": "just-added", "urlIdx": 1})
242
242
  real_names = provider_tags.connection_names
243
243
  try:
244
- live = {x["served"]: x["group"] for x in serve.onboard_candidates()["candidates"]}
244
+ # The picker's list is kept a few minutes (serve.LISTED_FOR); Refresh asks again.
245
+ kept = serve.onboard_candidates()
246
+ assert "just-added" not in {x["served"] for x in kept["candidates"]}, kept
247
+ live = {x["served"]: x["group"] for x in serve.onboard_candidates(fresh=True)["candidates"]}
245
248
 
246
249
  def refused(hub):
247
250
  raise OSError("401")
248
251
  provider_tags.connection_names = refused
249
- c = serve.onboard_candidates()
252
+ c = serve.onboard_candidates(fresh=True)
250
253
  finally:
251
254
  provider_tags.connection_names = real_names
252
255
  OWUI_MODELS.pop()
@@ -360,9 +360,9 @@ def main() -> int:
360
360
 
361
361
  modes = {}
362
362
 
363
- def fake_spawn(served, endpoint="", who="ui", mode="full"):
363
+ def fake_spawn(served, endpoint="", who="ui", mode="full", vision=False):
364
364
  order.append(served)
365
- modes[served] = mode
365
+ modes[served] = mode + (" vision" if vision else "")
366
366
  (runs / f"onboard-{served}.log").write_text(f"onboarding {served} from e -> alias {served}-x\n"
367
367
  + outcome[served][1], encoding="utf-8")
368
368
  return {"ok": True, "errors": [], "log": f"onboard-{served}.log"}, Proc(served)
@@ -397,7 +397,7 @@ def main() -> int:
397
397
  settle()
398
398
  stopped = {i["served"]: i["state"] for i in serve.onboard_queue()["items"]}
399
399
  order.remove("quiet")
400
- serve.queue_onboard([{"served": s, "endpoint": "e", "mode": m} for s, m in
400
+ serve.queue_onboard([{"served": s, "endpoint": "e", "mode": m, "vision": s == "quiet"} for s, m in
401
401
  (("ok", "full"), ("quiet", "quick"), ("bad", "quick-adhoc"))])
402
402
  settle()
403
403
  batch = {i["served"]: (i["state"], i["alias"], bool(i["note"])) for i in serve.onboard_queue()["items"]}
@@ -412,7 +412,7 @@ def main() -> int:
412
412
  and batch == {"ok": ("done", "ok-x", False), "quiet": ("no answer", "quiet-x", True),
413
413
  "bad": ("failed", "bad-x", True)}
414
414
  and cleared == [] and bad_input == (False, False, False)
415
- and modes == {"a": "full", "ok": "full", "quiet": "quick", "bad": "quick-adhoc"})
415
+ and modes == {"a": "full", "ok": "full", "quiet": "quick vision", "bad": "quick-adhoc"})
416
416
  print(("ok " if ok else "FAIL ") + "onboarding queue" + ("" if ok else f" -> {stopped} {order} {batch} {modes}"))
417
417
  failures += 0 if ok else 1
418
418
 
@@ -11,7 +11,10 @@ repository URL spells it, since the match is case-sensitive); every version is
11
11
  VERSION; package.json carries the name as mcpName (npm's proof) and the README
12
12
  PyPI shows carries `mcp-name: <name>` (PyPI's); the identifiers are the real
13
13
  package names; the arguments start the MCP server through launcher.py; the
14
- environment variables are the ones mcp_server.py reads. Standard library
14
+ environment variables are the ones mcp_server.py reads. The root's mcp.json,
15
+ mcp_config.json (Antigravity's name for it), plugin.json, .cursor-plugin and
16
+ skills/ (cursor.directory, Glama and Antigravity read the repo root, not
17
+ plugin/) start the same server and carry the plugin's skill. Standard library
15
18
  only: the full schema is validated by mcp-publisher in the release itself.
16
19
  """
17
20
 
@@ -71,7 +74,24 @@ def main() -> int:
71
74
  assert set(names) == read and len(names) == len(read), f"{kind}: lists {names}, mcp_server.py reads {read}"
72
75
  for e in p["environmentVariables"]:
73
76
  assert e.get("isSecret", False) == (e["name"] == "BENCHWARMERS_KEY"), e
74
- print(f"server.json ok: {name} {version}, npm and pypi")
77
+
78
+ # The repo root as cursor.directory and Glama read it: mcp.json starts the npm
79
+ # package's server, and skills/ is the Claude Code plugin's skill, word for word.
80
+ root_mcp = json.loads((ROOT / "mcp.json").read_text(encoding="utf-8"))["mcpServers"]["benchwarmers"]
81
+ assert root_mcp["command"] == "npx" and root_mcp["args"][:2] == ["-y", npm["name"]], root_mcp
82
+ assert launcher.plan(root_mcp["args"][2:]).get("script") == "mcp_server.py", root_mcp["args"]
83
+ agy = json.loads((ROOT / "mcp_config.json").read_text(encoding="utf-8"))["mcpServers"]["benchwarmers"]
84
+ assert agy == root_mcp, f"mcp_config.json (Antigravity's) {agy} differs from mcp.json {root_mcp}"
85
+ assert json.loads((ROOT / "plugin.json").read_text(encoding="utf-8"))["name"] == "benchwarmers"
86
+ cursor = json.loads((ROOT / ".cursor-plugin" / "plugin.json").read_text(encoding="utf-8"))
87
+ assert (cursor["skills"], cursor["mcpServers"]) == ("./skills/", "./mcp.json"), cursor
88
+ lf = lambda p: p.read_text(encoding="utf-8").replace("\r\n", "\n") # noqa: E731
89
+ copies = sorted(p.name for p in (ROOT / "skills").iterdir())
90
+ assert copies == sorted(p.name for p in (ROOT / "plugin" / "skills").iterdir()), copies
91
+ for skill in copies:
92
+ assert lf(ROOT / "skills" / skill / "SKILL.md") == lf(ROOT / "plugin" / "skills" / skill / "SKILL.md"), \
93
+ f"skills/{skill}/SKILL.md differs from plugin/skills/{skill}/SKILL.md: copy one over the other"
94
+ print(f"server.json ok: {name} {version}, npm and pypi; mcp.json, .cursor-plugin and skills/ match")
75
95
  return 0
76
96
 
77
97