@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
@@ -278,16 +278,21 @@ def discover_add() -> None:
278
278
  os.environ["BENCHWARMERS_KEY_CPA_ADMIN"] = "mgmt-test"
279
279
 
280
280
  onboarded = []
281
- serve.queue_onboard = lambda items, who="ui": onboarded.append((items[0]["served"], items[0]["endpoint"])) or {
281
+ serve.queue_onboard = lambda items, who="ui": onboarded.append(
282
+ (items[0]["served"], items[0]["endpoint"], items[0]["mode"], items[0]["vision"])) or {
282
283
  "ok": True, "errors": [], "items": []}
283
284
  owui.add = lambda c, m: (m, [f"owui added {m} on {c}"])
284
285
  r = serve.discover_add("groq", "vendor/new", endpoint="lite")
285
- assert r["ok"] and "/lite/model/new" in writes() and onboarded[-1] == ("vendor/new", "lite"), r
286
- r = serve.discover_add("openrouter", "vendor/y:free", endpoint="cpa")
287
- assert r["ok"] and onboarded[-1] == ("vendor/y:free", "cpa"), r
286
+ assert r["ok"] and "/lite/model/new" in writes() and onboarded[-1] == ("vendor/new", "lite", "full", False), r
287
+ # The confirm's Onboard as and Vision box reach the queue; a bad mode is refused before the hub has it.
288
+ r = serve.discover_add("openrouter", "vendor/y:free", endpoint="cpa", mode="quick-adhoc", vision=True)
289
+ assert r["ok"] and onboarded[-1] == ("vendor/y:free", "cpa", "quick-adhoc", True), r
288
290
  assert [m for m, p, _b in SEEN if "management" in p and m == "PATCH"] == ["PATCH"]
291
+ before = len(SEEN)
292
+ assert "mode must be" in serve.discover_add("openrouter", "vendor/q", endpoint="cpa", mode="slow")["errors"][0]
293
+ assert len(SEEN) == before, SEEN[before:]
289
294
  r = serve.discover_add("groq-api", "vendor/z") # no endpoint: Open WebUI, as before
290
- assert r["steps"] == ["owui added vendor/z on groq-api"] and onboarded[-1] == ("vendor/z", "owui"), r
295
+ assert r["steps"] == ["owui added vendor/z on groq-api"] and onboarded[-1] == ("vendor/z", "owui", "full", False), r
291
296
  assert serve.discover_add("groq-api", "vendor/z", endpoint="owui")["ok"]
292
297
  assert "not a hub" in serve.discover_add("groq", "v/z", endpoint="plain")["errors"][0]
293
298
  changes = [json.loads(line)["field"] for line in serve.CHANGES_PATH.read_text(encoding="utf-8").splitlines()]
@@ -132,6 +132,15 @@ def stranger(fake: str) -> None:
132
132
  assert c["ok"] and not c["errors"], c
133
133
  assert sorted(x["served"] for x in c["candidates"]) == ["vendor/c", "vendor/d", "vendor/e", "vendor/f"], c
134
134
  assert {x["group"] for x in c["candidates"]} == {"openrouter (direct)"} and not any(x["new"] for x in c["candidates"])
135
+ # Queued or onboarding now: left out until the queue is done with it.
136
+ kept_items = serve._OQ["items"]
137
+ serve._OQ["items"] = [{"served": "vendor/c", "endpoint": "openrouter", "state": "running"},
138
+ {"served": "vendor/d", "endpoint": "openrouter", "state": "done"}]
139
+ try:
140
+ busy = sorted(x["served"] for x in serve.onboard_candidates()["candidates"])
141
+ finally:
142
+ serve._OQ["items"] = kept_items
143
+ assert busy == ["vendor/d", "vendor/e", "vendor/f"], busy
135
144
 
136
145
  # The served check: the registered models only, not the aggregator's catalogue.
137
146
  assert check_deprecations.served() == {"vendor/a:free", "vendor/b:free"}
@@ -241,12 +250,15 @@ def owner(fake: str) -> None:
241
250
  OWUI_MODELS.append({"id": "just-added", "urlIdx": 1})
242
251
  real_names = provider_tags.connection_names
243
252
  try:
244
- live = {x["served"]: x["group"] for x in serve.onboard_candidates()["candidates"]}
253
+ # The picker's list is kept a few minutes (serve.LISTED_FOR); Refresh asks again.
254
+ kept = serve.onboard_candidates()
255
+ assert "just-added" not in {x["served"] for x in kept["candidates"]}, kept
256
+ live = {x["served"]: x["group"] for x in serve.onboard_candidates(fresh=True)["candidates"]}
245
257
 
246
258
  def refused(hub):
247
259
  raise OSError("401")
248
260
  provider_tags.connection_names = refused
249
- c = serve.onboard_candidates()
261
+ c = serve.onboard_candidates(fresh=True)
250
262
  finally:
251
263
  provider_tags.connection_names = real_names
252
264
  OWUI_MODELS.pop()
@@ -290,6 +290,16 @@ def main() -> int:
290
290
  check("rate limit (not sent" in r["error"] and "a calendar month" in r["error"] and "this month" in r["error"],
291
291
  "rpmo 3: the fourth call this month is refused", r["error"])
292
292
  check(client.chat("t-ok", "hi")["text"] != "", "another connection is not held by that cap")
293
+ # Seating asks the same question first (gateway.refused_now): held past any
294
+ # seat's wait, so not seated; a model with room is.
295
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
296
+ import gateway
297
+ import pacing
298
+ h = pacing.held("t-month", "monthprov")
299
+ check(h is not None and h[0] > gateway.SEAT_TIMEOUT and "this month" in h[1], "held() sees the monthly cap", h)
300
+ check(pacing.held("t-ok", gateway.route.connection_of("t-ok")) is None, "held() is None with room")
301
+ out = gateway.refused_now(["t-month", "t-ok"], 10, gateway.SEAT_TIMEOUT)
302
+ check(list(out) == ["t-month"] and "rate limit" in out["t-month"], "refused_now names only the held model", out)
293
303
 
294
304
  # The free-tier guard: a model that went paid is held until allowed.
295
305
  before = len(seen.get("paid", []))
@@ -322,8 +332,8 @@ def main() -> int:
322
332
  and "rpmo" not in ((serve.catalog.overrides().get("t-ok") or {}).get("limits") or {}),
323
333
  "a bad rpmo is refused; a model's own limits have none", bad)
324
334
  page = serve.quotas_page()
325
- check("What providers said is left" in page and "fakeprov" in page and "What the bench sent" in page,
326
- "the Quotas page shows the records and the ledger's counts")
335
+ check("id='quota-data'" in page and "fakeprov" in page and "What the bench sent" in page,
336
+ "the Quotas page carries its tiles and the ledger's counts")
327
337
 
328
338
  srv.shutdown()
329
339
  print("\nthe ledger, quotas and pacing hold" if not failures else f"\n{failures} FAILED")
@@ -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,8 +397,8 @@ 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
401
- (("ok", "full"), ("quiet", "quick"), ("bad", "quick-adhoc"))])
400
+ serve.queue_onboard([{"served": s, "endpoint": "e", "mode": m, "vision": s == "quiet"} for s, m in
401
+ (("ok", "full-adhoc"), ("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"]}
404
404
  cleared = serve.clear_onboard_queue()["items"]
@@ -412,10 +412,55 @@ 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-adhoc", "quiet": "quick vision", "bad": "quick-adhoc"}
416
+ and serve.ONBOARD_MODES["full-adhoc"] == ["--ad-hoc"]) # the suite now, then ad hoc
416
417
  print(("ok " if ok else "FAIL ") + "onboarding queue" + ("" if ok else f" -> {stopped} {order} {batch} {modes}"))
417
418
  failures += 0 if ok else 1
418
419
 
420
+ # Restart: refused unless serve.py's parent started it (nothing would start it
421
+ # again); refused while a job runs, unless forced; then the server shuts down.
422
+ import scheduler
423
+ kept = os.environ.get("BENCH_SERVED"), serve.SERVER
424
+ shut = []
425
+ try:
426
+ os.environ.pop("BENCH_SERVED", None)
427
+ alone = serve.restart()
428
+ os.environ["BENCH_SERVED"] = "1"
429
+ serve.SERVER = type("Server", (), {"shutdown": lambda self: shut.append(1)})()
430
+ scheduler.RUNNING["suite"] = True
431
+ busy, forced = serve.restart(), serve.restart(force=True)
432
+ time.sleep(0.8) # the shutdown waits half a second
433
+ finally:
434
+ scheduler.RUNNING.pop("suite", None)
435
+ serve.SERVER = kept[1]
436
+ os.environ.pop("BENCH_SERVED", None) if kept[0] is None else os.environ.update(BENCH_SERVED=kept[0])
437
+ ok = (not alone["ok"] and busy.get("busy") and not busy["ok"] and "suite" in busy["errors"][0]
438
+ and forced["ok"] and forced["cut"] and shut == [1])
439
+ print(("ok " if ok else "FAIL ") + "restart" + ("" if ok else f" -> {alone} {busy} {forced} {shut}"))
440
+ failures += 0 if ok else 1
441
+
442
+ # Action needed: an alert whose models have all left the bench is not shown,
443
+ # before the daily check rewrites the file (2026-10-28: a kilo connection
444
+ # alert stayed after Retire all 4).
445
+ kept_data, kept_reg = serve.DATA, serve.registered
446
+ with tempfile.TemporaryDirectory() as tmp:
447
+ (pathlib.Path(tmp) / "metrics").mkdir()
448
+ (pathlib.Path(tmp) / "metrics" / "alerts.json").write_text(json.dumps({
449
+ "connection:held:gone.example": {"kind": "connection", "message": "every model on gone.example is failing",
450
+ "aliases": ["g1", "g2"], "first_seen": "2026-10-28"},
451
+ "connection:held:here.example": {"kind": "connection", "message": "every model on here.example is failing",
452
+ "aliases": ["h1", "g1"], "first_seen": "2026-10-28"},
453
+ "retiring:x:old": {"kind": "retiring", "alias": "old", "message": "old retires", "first_seen": "2026-10-28"}}),
454
+ encoding="utf-8")
455
+ serve.DATA, serve.registered = pathlib.Path(tmp), lambda: {"h1-served": "h1"}
456
+ try:
457
+ shown = serve.problems()
458
+ finally:
459
+ serve.DATA, serve.registered = kept_data, kept_reg
460
+ ok = "here.example" in shown and "gone.example" not in shown and "old retires" not in shown
461
+ print(("ok " if ok else "FAIL ") + "action needed drops alerts on retired models")
462
+ failures += 0 if ok else 1
463
+
419
464
  # Dismiss: one picker key or many in one write, a change-log line each;
420
465
  # undo brings back only the ones named; nothing named is refused.
421
466
  serve.DISMISSED_PATH = cfg / "dismissed.json"
@@ -433,6 +478,14 @@ def main() -> int:
433
478
  print(("ok " if ok else "FAIL ") + "dismiss in bulk, and undo" + ("" if ok else f" -> {gone} {left} {log} {refused}"))
434
479
  failures += 0 if ok else 1
435
480
 
481
+ # The member picker's provider chips: a proxy's vendor, else the bare host.
482
+ names = {"nvidia-api": "nvidia", "api.unorouter.com": "unorouter", "router.requesty.ai": "requesty",
483
+ "cli-proxy-api/codex": "codex", "openrouter": "openrouter", "apihub.agnes-ai.com": "agnes-ai"}
484
+ got = {t: serve.provider_label(t) for t in names}
485
+ ok = got == names
486
+ print(("ok " if ok else "FAIL ") + "provider chip names" + ("" if ok else f" -> {got}"))
487
+ failures += 0 if ok else 1
488
+
436
489
  # The owner's files are as they were. alerts.json can change under a
437
490
  # running suite, so only what this test could do counts: the victim's
438
491
  # alerts cleared from the real file.
@@ -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