@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 +9 -5
- package/PRIVACY.md +16 -3
- package/README.md +20 -8
- package/USAGE.md +105 -14
- package/VERSION +1 -1
- package/cli.py +13 -4
- package/gateway.py +34 -2
- package/launcher.py +2 -1
- package/metrics/catalog.py +5 -0
- package/metrics/discover.py +68 -18
- package/metrics/learn.py +25 -1
- package/metrics/pacing.py +23 -3
- package/metrics/quota.py +41 -4
- package/metrics/quotaview.py +792 -0
- package/metrics/scheduler.py +2 -1
- package/metrics/sources.py +29 -6
- package/package.json +1 -1
- package/scripts/check.py +1 -0
- package/scripts/test_hubs.py +10 -5
- package/scripts/test_no_owui.py +9 -0
- package/scripts/test_quota.py +12 -2
- package/scripts/test_serve_edit.py +64 -2
- package/serve.py +311 -102
- package/ui/bench.css +92 -6
- package/ui/bench.js +544 -40
package/metrics/scheduler.py
CHANGED
|
@@ -168,6 +168,7 @@ def _start(name, args, due, running) -> None:
|
|
|
168
168
|
|
|
169
169
|
|
|
170
170
|
_stop = threading.Event()
|
|
171
|
+
RUNNING: dict = {} # job name -> True while it runs (serve.restart reads it)
|
|
171
172
|
|
|
172
173
|
|
|
173
174
|
def _loop() -> None:
|
|
@@ -180,7 +181,7 @@ def _loop() -> None:
|
|
|
180
181
|
except TimeoutError:
|
|
181
182
|
return # another dashboard already runs it
|
|
182
183
|
try:
|
|
183
|
-
running
|
|
184
|
+
running = RUNNING
|
|
184
185
|
conf = settings()
|
|
185
186
|
_log(f"scheduler started, mode {conf['mode']}, jobs {', '.join(enabled(conf))}")
|
|
186
187
|
while True:
|
package/metrics/sources.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
"""Public model lists the bench reads:
|
|
1
|
+
"""Public model lists the bench reads: FreeLLMAPI's free catalog and models.dev.
|
|
2
2
|
|
|
3
|
-
python metrics/sources.py # fetch
|
|
3
|
+
python metrics/sources.py # fetch them now (normally once a day)
|
|
4
|
+
|
|
5
|
+
Discover leads with FreeLLMAPI (easier for a person to follow: a page per
|
|
6
|
+
model, its limits, ranks and quirks) and fills the rest from models.dev, which
|
|
7
|
+
alone has prices, paid models, providers' API base URLs and modalities.
|
|
4
8
|
|
|
5
9
|
WHY THESE TWO
|
|
6
10
|
-------------
|
|
@@ -37,6 +41,9 @@ CACHE = DATA / "runs" / "cache"
|
|
|
37
41
|
FEEDS = {
|
|
38
42
|
"modelsdev": "https://models.dev/api.json",
|
|
39
43
|
"freellmapi": "https://api.freellmapi.co/v1/latest",
|
|
44
|
+
# The model pages freellmapi.co has (its sitemap): a page exists only for
|
|
45
|
+
# what its monthly snapshot lists, so Discover links only to those.
|
|
46
|
+
"freellmapi-pages": "https://freellmapi.co/sitemap-core.xml",
|
|
40
47
|
}
|
|
41
48
|
MAX_AGE_S = 20 * 3600
|
|
42
49
|
|
|
@@ -90,7 +97,8 @@ def fetch(name: str, force: bool = False) -> dict:
|
|
|
90
97
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
91
98
|
req = urllib.request.Request(FEEDS[name], headers={"User-Agent": "benchwarmers"})
|
|
92
99
|
with urllib.request.urlopen(req, timeout=90) as r:
|
|
93
|
-
data = json.load(r)
|
|
100
|
+
data = (json.load(r) if not FEEDS[name].endswith(".xml") else
|
|
101
|
+
{"slugs": sorted(set(re.findall(r"/model/([^<\"/]+)<", r.read().decode("utf-8"))))})
|
|
94
102
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
95
103
|
tmp = path.with_suffix(".part")
|
|
96
104
|
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
@@ -144,6 +152,20 @@ def freellmapi_entry(served: str, platform: str | None = None) -> dict | None:
|
|
|
144
152
|
return None
|
|
145
153
|
|
|
146
154
|
|
|
155
|
+
def freellmapi_name(display_name: str) -> str:
|
|
156
|
+
"""A FreeLLMAPI display name without its trailing note, "Llama 4 Scout (CF)"
|
|
157
|
+
-> "Llama 4 Scout": the name freellmapi.co gives the model on every platform."""
|
|
158
|
+
return re.sub(r"\s*\([^)]*\)\s*$", "", display_name or "").strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def freellmapi_page(display_name: str, pages: set[str]) -> str | None:
|
|
162
|
+
"""The model's freellmapi.co page, if `pages` (its sitemap) has it. The site
|
|
163
|
+
names a page by the display name without its note, lowercased, each run of
|
|
164
|
+
other characters one dash (buildBundles in its app.js, read 2026-10-28)."""
|
|
165
|
+
slug = "-".join(re.findall(r"[a-z0-9]+", freellmapi_name(display_name).lower()))
|
|
166
|
+
return f"https://freellmapi.co/model/{slug}" if slug in pages else None
|
|
167
|
+
|
|
168
|
+
|
|
147
169
|
def is_free(cost: dict | None) -> bool | None:
|
|
148
170
|
if not cost:
|
|
149
171
|
return None
|
|
@@ -156,9 +178,10 @@ def main() -> None:
|
|
|
156
178
|
for name in FEEDS:
|
|
157
179
|
try:
|
|
158
180
|
data = fetch(name, force=True)
|
|
159
|
-
size =
|
|
160
|
-
|
|
161
|
-
|
|
181
|
+
size, unit = {"modelsdev": (len(data), "providers"),
|
|
182
|
+
"freellmapi-pages": (len(data.get("slugs", [])), "model pages")}.get(
|
|
183
|
+
name, (len(data.get("models", [])), "models"))
|
|
184
|
+
print(f" {name}: {size} {unit} at {dt.datetime.now():%H:%M}")
|
|
162
185
|
except Exception as exc: # noqa: BLE001
|
|
163
186
|
print(f" {name}: could not fetch ({type(exc).__name__}: {exc}); keeping the cached copy")
|
|
164
187
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
@@ -39,6 +39,7 @@ CHECKS = [
|
|
|
39
39
|
("metrics/bench_collections.py", [], False),
|
|
40
40
|
("metrics/learn.py", ["--selftest"], False),
|
|
41
41
|
("metrics/quota.py", ["--selftest"], False),
|
|
42
|
+
("metrics/quotaview.py", ["--selftest"], False),
|
|
42
43
|
("metrics/discover.py", ["--selftest"], False),
|
|
43
44
|
("metrics/test_decide.py", [], False),
|
|
44
45
|
("metrics/wrong.py", [], False),
|
package/scripts/test_hubs.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
287
|
-
|
|
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()]
|
package/scripts/test_no_owui.py
CHANGED
|
@@ -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"}
|
package/scripts/test_quota.py
CHANGED
|
@@ -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("
|
|
326
|
-
"the Quotas page
|
|
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")
|
|
@@ -398,7 +398,7 @@ def main() -> int:
|
|
|
398
398
|
stopped = {i["served"]: i["state"] for i in serve.onboard_queue()["items"]}
|
|
399
399
|
order.remove("quiet")
|
|
400
400
|
serve.queue_onboard([{"served": s, "endpoint": "e", "mode": m, "vision": s == "quiet"} for s, m in
|
|
401
|
-
(("ok", "full"), ("quiet", "quick"), ("bad", "quick-adhoc"))])
|
|
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 vision", "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,23 @@ 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
|
+
|
|
489
|
+
# Got it clears a notice (NVIDIA changed its figures), never a state still wrong.
|
|
490
|
+
alerts.raise_alert("test:notice", "limits", "a notice", dismiss=True)
|
|
491
|
+
alerts.raise_alert("test:state", "limits", "a state")
|
|
492
|
+
ok = (serve.dismiss_alert("test:notice")["ok"] and not serve.dismiss_alert("test:state")["ok"]
|
|
493
|
+
and "test:notice" not in alerts.active() and "test:state" in alerts.active())
|
|
494
|
+
alerts.clear("test:state")
|
|
495
|
+
print(("ok " if ok else "FAIL ") + "Got it dismisses a notice, not a state")
|
|
496
|
+
failures += 0 if ok else 1
|
|
497
|
+
|
|
436
498
|
# The owner's files are as they were. alerts.json can change under a
|
|
437
499
|
# running suite, so only what this test could do counts: the victim's
|
|
438
500
|
# alerts cleared from the real file.
|