@wendellresearch-ai/benchwarmers 0.5.5 → 0.5.6
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/AGENTS.md +4 -1
- package/GATEWAY.md +26 -1
- package/PRIVACY.md +6 -1
- package/README.md +16 -4
- package/SECURITY.md +2 -1
- package/USAGE.md +16 -5
- package/VERSION +1 -1
- package/cli.py +12 -1
- package/gateway.py +29 -9
- package/mcp_server.py +2 -1
- package/metrics/alerts.py +25 -0
- package/metrics/bench_collections.py +31 -6
- package/metrics/catalog.py +2 -5
- package/metrics/discover.py +191 -11
- package/metrics/owui.py +26 -0
- package/metrics/route.py +10 -3
- package/metrics/sources.py +15 -2
- package/package.json +1 -1
- package/scripts/test_formats.py +61 -1
- package/scripts/test_serve_edit.py +19 -0
- package/serve.py +75 -30
- package/ui/bench.js +39 -13
package/metrics/discover.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""One list of models worth adding, from FreeLLMAPI
|
|
1
|
+
"""One list of models worth adding, from FreeLLMAPI, models.dev and the providers' own lists, deduped.
|
|
2
2
|
|
|
3
3
|
python metrics/discover.py # summary: free models by reach, new this week
|
|
4
|
+
python metrics/discover.py --refresh # fetch every list now, as Discover's Refresh does
|
|
4
5
|
|
|
5
6
|
The shopping list: Discover's Add (serve.py) puts a model on the bench by one
|
|
6
7
|
of the routes below. A model offered by the same provider in both sources
|
|
@@ -34,7 +35,11 @@ ROUTES
|
|
|
34
35
|
------
|
|
35
36
|
The ways this install can reach a provider, each a row's `routes`:
|
|
36
37
|
openwebui a connection in Open WebUI, known because a bench model on the
|
|
37
|
-
Open WebUI hub (endpoints.find_hub) is served from it
|
|
38
|
+
Open WebUI hub (endpoints.find_hub) is served from it, or from
|
|
39
|
+
the providers' lists (below). A row is that connection's when
|
|
40
|
+
the provider's API host is the connection's, or its id
|
|
41
|
+
(models.dev's or FreeLLMAPI's) is the connection's name or a
|
|
42
|
+
part of its host: FreeLLMAPI's xkiro is api.xkiro.com. Adding
|
|
38
43
|
goes through metrics/owui.py, so the model also appears in the
|
|
39
44
|
chats. None without an Open WebUI hub.
|
|
40
45
|
direct an endpoint of its own in config/endpoints.json whose name or
|
|
@@ -50,9 +55,28 @@ The ways this install can reach a provider, each a row's `routes`:
|
|
|
50
55
|
The same model offered by two providers (OpenRouter and NVIDIA) has a route
|
|
51
56
|
on each; the dashboard's Discover popup lists them together by `norm`.
|
|
52
57
|
|
|
53
|
-
|
|
58
|
+
THE PROVIDERS' OWN LISTS
|
|
59
|
+
------------------------
|
|
60
|
+
The catalogs trail the providers by days, so a model a provider started
|
|
61
|
+
serving this morning is in neither. Open WebUI passes each connection's own
|
|
62
|
+
/models through (owui.lists), whatever its Model IDs list allows, so
|
|
63
|
+
refresh() keeps every connection's in runs/cache/provider-lists.json. A model
|
|
64
|
+
there that neither catalog has for that provider is a row of its own, source
|
|
65
|
+
"provider": its id, and what the list says of its name, context, price and
|
|
66
|
+
abilities (each provider says different things; many say nothing of price,
|
|
67
|
+
which is then unknown, not paid). Only through Open WebUI: the providers'
|
|
68
|
+
keys stay where they are, and nothing is sent anywhere new.
|
|
69
|
+
|
|
70
|
+
LINKS
|
|
71
|
+
-----
|
|
72
|
+
A row's `page` is its freellmapi.co page (where the sitemap has one), and
|
|
73
|
+
`md_page` its models.dev page: /models/<lab>/<id> by models.dev's canonical
|
|
74
|
+
id, else the provider's own page there.
|
|
75
|
+
|
|
76
|
+
NEW is the date a model was first seen in any list, kept in
|
|
54
77
|
runs/discover-seen.json (gitignored). The first build records everything as a
|
|
55
|
-
baseline, so only models that appear afterwards are badged new
|
|
78
|
+
baseline, so only models that appear afterwards are badged new; so does the
|
|
79
|
+
first list of each connection.
|
|
56
80
|
"""
|
|
57
81
|
|
|
58
82
|
from __future__ import annotations
|
|
@@ -61,6 +85,7 @@ import datetime as dt
|
|
|
61
85
|
import json
|
|
62
86
|
import pathlib
|
|
63
87
|
import sys
|
|
88
|
+
from urllib.parse import urlsplit
|
|
64
89
|
|
|
65
90
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
66
91
|
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
@@ -74,6 +99,84 @@ FREELLMAPI_TO_CONNECTION = {"groq": "groq", "nvidia": "nvidia-api", "mistral": "
|
|
|
74
99
|
"openrouter": "openrouter", "ollama": "ollama-cloud",
|
|
75
100
|
"cerebras": "cerebras", "llm7": "llm7-api"}
|
|
76
101
|
NEW_DAYS = 7
|
|
102
|
+
LISTS = "provider-lists" # runs/cache/provider-lists.json, written by refresh()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def refresh(force: bool = False) -> list[str]:
|
|
106
|
+
"""Fetch the catalogs (sources.FEEDS) and the providers' own lists, each at
|
|
107
|
+
most once a day unless `force`. What went wrong, as sentences: a list that
|
|
108
|
+
could not be fetched keeps its cached copy."""
|
|
109
|
+
import endpoints
|
|
110
|
+
said = []
|
|
111
|
+
for name in sources.FEEDS:
|
|
112
|
+
try:
|
|
113
|
+
sources.fetch(name, force)
|
|
114
|
+
except Exception as exc: # noqa: BLE001
|
|
115
|
+
said.append(f"could not fetch {name} ({exc}); kept the cached copy")
|
|
116
|
+
age = sources.age_s(LISTS)
|
|
117
|
+
if not endpoints.find_hub("openwebui") or (not force and age is not None and age < sources.MAX_AGE_S):
|
|
118
|
+
return said
|
|
119
|
+
import owui
|
|
120
|
+
try:
|
|
121
|
+
got, failed = owui.lists()
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
return said + [f"could not ask Open WebUI for its providers' lists ({exc}); kept the cached copy"]
|
|
124
|
+
old = {c["names"][0]: c for c in sources.cached(LISTS).get("connections") or []}
|
|
125
|
+
conns = [{**c, "models": [s for s in map(_slim, c["models"]) if s]} for c in got]
|
|
126
|
+
sources.store(LISTS, {"at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
127
|
+
"connections": conns + [old[n] for n in failed if n in old]})
|
|
128
|
+
return said + [f"{n} would not list its models ({why})" + ("; kept its last list" if n in old else "")
|
|
129
|
+
for n, why in failed.items()]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _slim(m: dict) -> dict | None:
|
|
133
|
+
"""What Discover keeps of one model in a provider's own list; None when the
|
|
134
|
+
list says it is not a chat model (images, audio, embeddings, another API).
|
|
135
|
+
Each provider says it its own way: these are the ways seen 2026-11-07 in
|
|
136
|
+
OpenRouter's, Requesty's, xKiro's, Mistral's and UnoRouter's lists."""
|
|
137
|
+
arch = m.get("architecture") if isinstance(m.get("architecture"), dict) else {}
|
|
138
|
+
caps = m.get("capabilities") if isinstance(m.get("capabilities"), dict) else {}
|
|
139
|
+
outs = m.get("output_modalities") or arch.get("output_modalities")
|
|
140
|
+
ins = m.get("input_modalities") or arch.get("input_modalities")
|
|
141
|
+
apis = m.get("supported_endpoint_types")
|
|
142
|
+
if ((isinstance(outs, list) and outs and "text" not in outs) or m.get("modality") not in (None, "chat", "text")
|
|
143
|
+
or m.get("api") not in (None, "chat") or caps.get("completion_chat") is False
|
|
144
|
+
or (isinstance(apis, list) and apis and "openai" not in apis)):
|
|
145
|
+
return None
|
|
146
|
+
tier, price = m.get("access_tier"), m.get("pricing") if isinstance(m.get("pricing"), dict) else {}
|
|
147
|
+
nums = []
|
|
148
|
+
for v in (price.get("prompt"), price.get("completion"), price.get("input"), price.get("output"),
|
|
149
|
+
m.get("input_price"), m.get("output_price")):
|
|
150
|
+
try:
|
|
151
|
+
nums.append(float(v))
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
pass
|
|
154
|
+
vision = caps.get("vision", m.get("supports_vision"))
|
|
155
|
+
if vision is None and isinstance(ins, list):
|
|
156
|
+
vision = "image" in ins
|
|
157
|
+
ctx = m.get("context_length") or m.get("context_window") or m.get("max_context_length")
|
|
158
|
+
return {"id": str(m["id"]), "name": m.get("display_name") or m.get("name"),
|
|
159
|
+
"context": ctx if isinstance(ctx, int) and ctx > 0 else None,
|
|
160
|
+
"free": (True if str(m["id"]).endswith(":free") or tier == "free" else
|
|
161
|
+
False if tier in ("paid", "premium") else all(x == 0 for x in nums) if nums else None),
|
|
162
|
+
"tools": caps.get("tools", caps.get("function_calling", m.get("supports_tool_calling"))),
|
|
163
|
+
"vision": vision, "reasoning": caps.get("reasoning", m.get("supports_reasoning"))}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _host(base: str) -> str:
|
|
167
|
+
return urlsplit(base).hostname or "" if "//" in base else base
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _conn_for(ids: list[str], api: str | None, hosts: dict[str, str]) -> str | None:
|
|
171
|
+
"""The Open WebUI connection (`hosts`: name -> its API host, the bench's
|
|
172
|
+
names first) that serves this provider: the provider's API host is its
|
|
173
|
+
host, or one of the provider's `ids` is its name or a part of its host."""
|
|
174
|
+
want = _host(api or "")
|
|
175
|
+
for name, host in hosts.items():
|
|
176
|
+
parts = host.split(".")[:-1] if "." in host else [host]
|
|
177
|
+
if (want and host == want) or any(i and (i == name or i in parts) for i in ids):
|
|
178
|
+
return name
|
|
179
|
+
return None
|
|
77
180
|
|
|
78
181
|
|
|
79
182
|
def _on_bench() -> list[tuple[str, str, str, str, str]]:
|
|
@@ -168,14 +271,29 @@ def build() -> list[dict]:
|
|
|
168
271
|
platform_names = {p.get("id"): p.get("name") for p in fl.get("platforms") or []}
|
|
169
272
|
md_conn = {v: k for k, v in sources.connection_to_modelsdev().items()}
|
|
170
273
|
bench = _on_bench()
|
|
171
|
-
# Open WebUI's connections, as far as the bench can tell without asking it
|
|
274
|
+
# Open WebUI's connections, as far as the bench can tell without asking it:
|
|
275
|
+
# its models' connections, then the providers' lists, with their API hosts.
|
|
172
276
|
owui = endpoints.find_hub("openwebui")
|
|
173
|
-
|
|
277
|
+
lists = (sources.cached(LISTS).get("connections") or []) if owui else []
|
|
278
|
+
hosts: dict[str, str] = dict.fromkeys(c for ep, c, *_ in bench if ep == owui and c != "?")
|
|
279
|
+
for c in lists:
|
|
280
|
+
for n in c["names"]:
|
|
281
|
+
hosts[n] = _host(c["base"])
|
|
282
|
+
hosts = {n: h or n for n, h in hosts.items()}
|
|
283
|
+
owui_conns = set(hosts)
|
|
284
|
+
|
|
285
|
+
def conn_of(ids: list[str], api: str | None, known: str | None) -> str | None:
|
|
286
|
+
return known if known in hosts else _conn_for(ids, api, hosts) or known
|
|
287
|
+
|
|
174
288
|
direct = _direct(md)
|
|
175
289
|
rows: dict[tuple[str, str], dict] = {}
|
|
290
|
+
# models.dev's own page for a model: by its canonical id, which names its lab.
|
|
291
|
+
labs = {str(m["canonical_model_id"]).split("/")[0] for p in md.values()
|
|
292
|
+
for m in (p.get("models") or {}).values() if m.get("canonical_model_id")}
|
|
176
293
|
|
|
177
294
|
for prov, p in md.items():
|
|
178
295
|
openai_like = bool(p.get("api")) or p.get("npm") == "@ai-sdk/openai-compatible"
|
|
296
|
+
conn = conn_of([prov], p.get("api"), md_conn.get(prov))
|
|
179
297
|
for mid, m in (p.get("models") or {}).items():
|
|
180
298
|
out_mods = (m.get("modalities") or {}).get("output") or ["text"]
|
|
181
299
|
if "text" not in out_mods or m.get("status") == "deprecated":
|
|
@@ -193,8 +311,10 @@ def build() -> list[dict]:
|
|
|
193
311
|
"vision": "image" in ((m.get("modalities") or {}).get("input") or []),
|
|
194
312
|
"released": m.get("release_date"), "knowledge": m.get("knowledge"),
|
|
195
313
|
"open_weights": m.get("open_weights"), "api": p.get("api"),
|
|
196
|
-
"openai_like": openai_like, "connection":
|
|
197
|
-
"rank": None, "speed": None, "size": None, "budget": None, "quirks": [], "page": None
|
|
314
|
+
"openai_like": openai_like, "connection": conn, "limits": None,
|
|
315
|
+
"rank": None, "speed": None, "size": None, "budget": None, "quirks": [], "page": None,
|
|
316
|
+
"md_page": "https://models.dev/" + (f"models/{m['canonical_model_id']}" if m.get("canonical_model_id")
|
|
317
|
+
else f"models/{prov}/{mid}" if prov in labs else f"providers/{prov}")}
|
|
198
318
|
|
|
199
319
|
for m in fl.get("models") or []:
|
|
200
320
|
if (m.get("modality") or "chat") != "chat":
|
|
@@ -222,8 +342,30 @@ def build() -> list[dict]:
|
|
|
222
342
|
"cost": None, "context": m.get("contextWindow"), "output": None,
|
|
223
343
|
"tools": m.get("supportsTools"), "reasoning": None,
|
|
224
344
|
"vision": m.get("supportsVision"), "released": None, "knowledge": None,
|
|
225
|
-
"open_weights": None, "api": None,
|
|
226
|
-
"openai_like": False, "connection": FREELLMAPI_TO_CONNECTION.get(plat)}
|
|
345
|
+
"open_weights": None, "api": None, "md_page": None,
|
|
346
|
+
"openai_like": False, "connection": conn_of([prov, plat], None, FREELLMAPI_TO_CONNECTION.get(plat))}
|
|
347
|
+
|
|
348
|
+
# What each connection's provider lists that neither catalog has for it.
|
|
349
|
+
listed = set()
|
|
350
|
+
for c in lists:
|
|
351
|
+
conn = c["names"][0] # the bench's name for it, as conn_of picks
|
|
352
|
+
provs = sorted({r["provider"] for r in rows.values() if r["connection"] in c["names"]}) or [conn]
|
|
353
|
+
have = {k[1] for k in rows if k[0] in provs}
|
|
354
|
+
name = next((r["provider_name"] for r in rows.values() if r["provider"] == provs[0]), c["names"][-1])
|
|
355
|
+
listed.add(conn)
|
|
356
|
+
for m in c["models"]:
|
|
357
|
+
key = (provs[0], sources.norm_model(m["id"]))
|
|
358
|
+
if key[1] in have:
|
|
359
|
+
continue
|
|
360
|
+
have.add(key[1])
|
|
361
|
+
rows[key] = {
|
|
362
|
+
"in_mods": ["text", "image"] if m.get("vision") else ["text"], "out_mods": ["text"],
|
|
363
|
+
"provider": provs[0], "provider_name": name, "model": m["id"], "name": m.get("name") or m["id"],
|
|
364
|
+
"sources": ["provider"], "free": m.get("free"), "cost": None, "context": m.get("context"),
|
|
365
|
+
"output": None, "tools": m.get("tools"), "reasoning": m.get("reasoning"), "vision": m.get("vision"),
|
|
366
|
+
"released": None, "knowledge": None, "open_weights": None, "api": c["base"], "openai_like": True,
|
|
367
|
+
"connection": conn, "limits": None, "rank": None, "speed": None, "size": None, "budget": None,
|
|
368
|
+
"quirks": [], "page": None, "md_page": None}
|
|
227
369
|
|
|
228
370
|
# The same model through ANOTHER provider is fine to add, but worth knowing.
|
|
229
371
|
by_model: dict[str, list[str]] = {}
|
|
@@ -234,12 +376,14 @@ def build() -> list[dict]:
|
|
|
234
376
|
seen = _read_seen()
|
|
235
377
|
today = dt.date.today().isoformat()
|
|
236
378
|
baseline = not seen
|
|
379
|
+
first_list = {c for c in listed if f"list|{c}" not in seen} # a connection's first list is its baseline
|
|
237
380
|
walled = {p for p, on in sources.free_in_own_client_only().items() if on}
|
|
238
381
|
out = []
|
|
239
382
|
for key, r in rows.items():
|
|
240
383
|
k = "|".join(key)
|
|
241
384
|
if k not in seen:
|
|
242
|
-
seen[k] = "baseline" if baseline
|
|
385
|
+
seen[k] = ("baseline" if baseline or (r["sources"] == ["provider"] and r["connection"] in first_list)
|
|
386
|
+
else today)
|
|
243
387
|
r["first_seen"] = seen[k]
|
|
244
388
|
r["new"] = seen[k] not in ("baseline",) and seen[k] >= (dt.date.today() - dt.timedelta(days=NEW_DAYS)).isoformat()
|
|
245
389
|
if r["free"] and r["provider"] in walled:
|
|
@@ -257,6 +401,7 @@ def build() -> list[dict]:
|
|
|
257
401
|
"connected" if r["routes"] else
|
|
258
402
|
"easy to add" if r["openai_like"] else "other")
|
|
259
403
|
out.append(r)
|
|
404
|
+
seen.update({f"list|{c}": today for c in first_list})
|
|
260
405
|
_write_seen(seen)
|
|
261
406
|
order = {"connected": 0, "easy to add": 1, "on bench": 2, "other": 3}
|
|
262
407
|
# Within each reach, FreeLLMAPI's smartest first; the models it does not rank
|
|
@@ -343,6 +488,38 @@ def _selftest() -> None:
|
|
|
343
488
|
assert big["page"] == "https://freellmapi.co/model/big-one" and big["quirks"] == [
|
|
344
489
|
{"title": "T", "body": "B", "severity": "info"}] and big["vision"], big
|
|
345
490
|
assert [r["model"] for r in rows] == ["acme/tiny", "acme/big", "acme/small"], [r["model"] for r in rows]
|
|
491
|
+
# A connection is found by its API host or by the provider's id in its host; the bench's name wins.
|
|
492
|
+
hosts = {"xk": "api.xkiro.com", "api.xkiro.com": "api.xkiro.com", "router.requesty.ai": "router.requesty.ai",
|
|
493
|
+
"cli-proxy-api": "203.0.113.7"}
|
|
494
|
+
assert _conn_for(["xkiro"], None, hosts) == "xk"
|
|
495
|
+
assert _conn_for(["requesty"], "https://router.requesty.ai/v1", hosts) == "router.requesty.ai"
|
|
496
|
+
assert _conn_for(["com"], None, hosts) is None and _conn_for(["acme"], "https://acme.example/v1", hosts) is None
|
|
497
|
+
# A provider's own list: chat models only, price where it says one, unknown where it does not.
|
|
498
|
+
assert _slim({"id": "a", "architecture": {"output_modalities": ["image"]}}) is None
|
|
499
|
+
assert _slim({"id": "b", "supported_endpoint_types": ["aihorde"]}) is None
|
|
500
|
+
assert _slim({"id": "c", "capabilities": {"completion_chat": False}}) is None
|
|
501
|
+
assert _slim({"id": "d:free"})["free"] is True and _slim({"id": "e"})["free"] is None
|
|
502
|
+
assert _slim({"id": "f", "pricing": {"prompt": "0", "completion": "0"}})["free"] is True
|
|
503
|
+
assert _slim({"id": "g", "input_price": 2.5e-07, "output_price": 1e-06})["free"] is False
|
|
504
|
+
assert _slim({"id": "h", "access_tier": "premium", "capabilities": {"vision": True}, "context_length": 9})[
|
|
505
|
+
"free"] is False and _slim({"id": "h", "capabilities": {"vision": True}})["vision"] is True
|
|
506
|
+
# With a list cached: acme's connection is found by its host, the list's new model is a row of its
|
|
507
|
+
# own (the first list of a connection is a baseline, so not new), and models.dev links each model.
|
|
508
|
+
feeds["provider-lists"] = {"connections": [{"names": ["acme-conn"], "base": "https://acme.example/v1",
|
|
509
|
+
"models": [_slim({"id": "acme/big"}), _slim({"id": "acme/fresh"})]}]}
|
|
510
|
+
import endpoints
|
|
511
|
+
try:
|
|
512
|
+
sources.cached = lambda name: feeds.get(name, {})
|
|
513
|
+
globals().update(_on_bench=lambda: [], SEEN=pathlib.Path(tempfile.mkdtemp()) / "seen.json")
|
|
514
|
+
endpoints_hub, endpoints.find_hub = endpoints.find_hub, lambda which: "owui-ep" if which == "openwebui" else None
|
|
515
|
+
rows = {r["model"]: r for r in build() if r["provider"] == "acme"}
|
|
516
|
+
finally:
|
|
517
|
+
sources.cached, endpoints.find_hub = kept[0], endpoints_hub
|
|
518
|
+
globals().update(_on_bench=kept[1], SEEN=kept[2])
|
|
519
|
+
assert set(rows) == {"acme/big", "acme/small", "acme/tiny", "acme/fresh"}, sorted(rows)
|
|
520
|
+
assert rows["acme/big"]["connection"] == "acme-conn" and rows["acme/big"]["reach"] == "connected", rows["acme/big"]
|
|
521
|
+
assert rows["acme/fresh"]["sources"] == ["provider"] and not rows["acme/fresh"]["new"], rows["acme/fresh"]
|
|
522
|
+
assert rows["acme/big"]["md_page"] == "https://models.dev/providers/acme", rows["acme/big"]["md_page"]
|
|
346
523
|
print("discover selftest ok")
|
|
347
524
|
|
|
348
525
|
|
|
@@ -351,6 +528,9 @@ def main() -> None:
|
|
|
351
528
|
if "--selftest" in sys.argv:
|
|
352
529
|
_selftest()
|
|
353
530
|
return
|
|
531
|
+
if "--refresh" in sys.argv:
|
|
532
|
+
for line in refresh(force=True) or ["every list fetched"]:
|
|
533
|
+
print(" ", line)
|
|
354
534
|
rows = build()
|
|
355
535
|
free = [r for r in rows if r["free"]]
|
|
356
536
|
by = {}
|
package/metrics/owui.py
CHANGED
|
@@ -114,6 +114,32 @@ def connections() -> dict[str, tuple[int, str]]:
|
|
|
114
114
|
return out
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
def lists() -> tuple[list[dict], dict[str, str]]:
|
|
118
|
+
"""What each connection's provider serves now, by its own /models, which
|
|
119
|
+
Open WebUI passes through at /openai/models/<index> whatever the
|
|
120
|
+
connection's Model IDs list allows: [{"names", "base", "models"}], names
|
|
121
|
+
as connections() has them (the bench's first), and {name: why} for the
|
|
122
|
+
connections that would not say. Switched-off connections are left out."""
|
|
123
|
+
names: dict[int, list[str]] = collections.defaultdict(list)
|
|
124
|
+
for name, (idx, _tag) in connections().items():
|
|
125
|
+
names[idx].append(name)
|
|
126
|
+
cfg = _call("/openai/config")
|
|
127
|
+
bases = cfg.get("OPENAI_API_BASE_URLS") or []
|
|
128
|
+
out, failed = [], {}
|
|
129
|
+
for idx, ns in sorted(names.items()):
|
|
130
|
+
if idx >= len(bases) or (cfg.get("OPENAI_API_CONFIGS") or {}).get(str(idx), {}).get("enable") is False:
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
r = _call(f"/openai/models/{idx}")
|
|
134
|
+
except Exception as exc: # noqa: BLE001
|
|
135
|
+
failed[ns[0]] = str(exc)
|
|
136
|
+
continue
|
|
137
|
+
data = r.get("data") if isinstance(r, dict) else r
|
|
138
|
+
out.append({"names": ns, "base": bases[idx],
|
|
139
|
+
"models": [m for m in data or [] if isinstance(m, dict) and m.get("id")]})
|
|
140
|
+
return out, failed
|
|
141
|
+
|
|
142
|
+
|
|
117
143
|
def plan(connection: str, model_id: str) -> dict:
|
|
118
144
|
"""What adding would change. Raises ValueError when it cannot be done here."""
|
|
119
145
|
release, note = version()
|
package/metrics/route.py
CHANGED
|
@@ -273,7 +273,7 @@ def suite_recall() -> dict[str, tuple[int, int, int]]:
|
|
|
273
273
|
def panel(shape: str | None = None, large: bool = True,
|
|
274
274
|
nominated: list[str] | None = None,
|
|
275
275
|
members: list[str] | None = None, explorers: int = 2,
|
|
276
|
-
avoid: set[str] | None = None) -> dict:
|
|
276
|
+
avoid: set[str] | None = None, stand_ins: int = 0) -> dict:
|
|
277
277
|
"""`large=True` means the task is a big one — most of ours are.
|
|
278
278
|
|
|
279
279
|
`members` is a bench-gateway COLLECTION: models the caller always runs. With
|
|
@@ -282,7 +282,8 @@ def panel(shape: str | None = None, large: bool = True,
|
|
|
282
282
|
never with a member or a member's family. `avoid` keeps models out of
|
|
283
283
|
those automatic picks (the gateway passes the ones on a retiring alert, so
|
|
284
284
|
a seat is not spent on a model already known to be failing); a nomination
|
|
285
|
-
still seats one.
|
|
285
|
+
still seats one. `stand_ins` more are picked the same way, for members out
|
|
286
|
+
of quota (the gateway); fewer come back if the pool runs out.
|
|
286
287
|
|
|
287
288
|
`nominated` names models the operator wants on the panel today, and they
|
|
288
289
|
take EXPLORER seats — never the primary or the second.
|
|
@@ -469,6 +470,12 @@ def panel(shape: str | None = None, large: bool = True,
|
|
|
469
470
|
|
|
470
471
|
explorer, explorer2 = seats[0], seats[1]
|
|
471
472
|
|
|
473
|
+
standing_in = []
|
|
474
|
+
while len(standing_in) < stand_ins and (pick := stalest()):
|
|
475
|
+
chosen.add(pick["model"])
|
|
476
|
+
seated_families.add(pick["family"])
|
|
477
|
+
standing_in.append(pick)
|
|
478
|
+
|
|
472
479
|
# RESERVE: the fallback when the panel meets a rate limit.
|
|
473
480
|
#
|
|
474
481
|
# This replaces the hard rule that seats must sit on different
|
|
@@ -489,7 +496,7 @@ def panel(shape: str | None = None, large: bool = True,
|
|
|
489
496
|
key=lambda r: (r["found"], paid(r)), default=None)
|
|
490
497
|
|
|
491
498
|
return {"primary": primary, "second": second, "explorer": explorer,
|
|
492
|
-
"explorer2": explorer2, "reserve": reserve,
|
|
499
|
+
"explorer2": explorer2, "reserve": reserve, "stand_ins": standing_in,
|
|
493
500
|
"considered": len(rows),
|
|
494
501
|
"untried": sum(1 for r in rows if not r["offloads"]),
|
|
495
502
|
"contenders": len(contenders),
|
package/metrics/sources.py
CHANGED
|
@@ -93,17 +93,30 @@ FREELLMAPI_TO_MODELSDEV = {
|
|
|
93
93
|
def fetch(name: str, force: bool = False) -> dict:
|
|
94
94
|
"""A feed, from the cache when it is under a day old."""
|
|
95
95
|
path = CACHE / f"{name}.json"
|
|
96
|
-
|
|
96
|
+
age = age_s(name)
|
|
97
|
+
if not force and age is not None and age < MAX_AGE_S:
|
|
97
98
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
98
99
|
req = urllib.request.Request(FEEDS[name], headers={"User-Agent": "benchwarmers"})
|
|
99
100
|
with urllib.request.urlopen(req, timeout=90) as r:
|
|
100
101
|
data = (json.load(r) if not FEEDS[name].endswith(".xml") else
|
|
101
102
|
{"slugs": sorted(set(re.findall(r"/model/([^<\"/]+)<", r.read().decode("utf-8"))))})
|
|
103
|
+
store(name, data)
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def store(name: str, data: dict) -> None:
|
|
108
|
+
"""Write one cached list whole, never half."""
|
|
102
109
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
path = CACHE / f"{name}.json"
|
|
103
111
|
tmp = path.with_suffix(".part")
|
|
104
112
|
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
105
113
|
tmp.replace(path)
|
|
106
|
-
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def age_s(name: str) -> float | None:
|
|
117
|
+
"""Seconds since a cached list was written; None if it never was."""
|
|
118
|
+
path = CACHE / f"{name}.json"
|
|
119
|
+
return time.time() - path.stat().st_mtime if path.exists() else None
|
|
107
120
|
|
|
108
121
|
|
|
109
122
|
def cached(name: str) -> dict:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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/test_formats.py
CHANGED
|
@@ -274,7 +274,8 @@ def gateway_routes() -> None:
|
|
|
274
274
|
"merge": {"members": ["m1", "m2"], "reply": "merged", "merger": "mg", "description": "x"},
|
|
275
275
|
"broken": {"members": ["m1", "boom"], "description": "x"},
|
|
276
276
|
"small": {"members": ["tiny"], "description": "x"},
|
|
277
|
-
"other": {"members": ["m2"], "description": "x"}
|
|
277
|
+
"other": {"members": ["m2"], "description": "x"},
|
|
278
|
+
"dear": {"members": ["m1"], "description": "held back", "ad_hoc": True}})
|
|
278
279
|
import gateway
|
|
279
280
|
import guard
|
|
280
281
|
import serve
|
|
@@ -493,6 +494,65 @@ def gateway_routes() -> None:
|
|
|
493
494
|
one = json.loads(ok("/v1beta/models/bench/core")[0])
|
|
494
495
|
assert one["name"] == "models/bench/core" and "generateContent" in one["supportedGenerationMethods"]
|
|
495
496
|
assert call("/v1beta/models/bench/nope")[0] == 404
|
|
497
|
+
# Ad hoc: on no list and in no refusal, so nobody picks it; named, it runs and can be looked up.
|
|
498
|
+
for listing in (ok("/v1/models")[0], ok("/api/tags")[0], ok("/v1beta/models")[0]):
|
|
499
|
+
assert "bench/dear" not in listing and "bench/core" in listing, listing
|
|
500
|
+
assert "bench/dear" not in call("/v1/chat/completions", {"model": "bench/nope", "messages": [
|
|
501
|
+
{"role": "user", "content": user}]})[2]
|
|
502
|
+
assert ok("/api/show", {"model": "bench/dear"}) and ok("/v1beta/models/bench/dear")
|
|
503
|
+
asked = json.loads(ok("/v1/chat/completions", {"model": "bench/dear", "messages": [
|
|
504
|
+
{"role": "user", "content": user}]})[0])
|
|
505
|
+
assert asked["model"] == "bench/dear" and [s["alias"] for s in asked["bench"]["seats"]] == ["m1"], asked
|
|
506
|
+
seats.asked.clear()
|
|
507
|
+
made = call("/api/collection", {"name": "dear", "members": ["m1"], "ad_hoc": True}, on=dash.server_port)
|
|
508
|
+
assert json.loads(made[2])["ok"] and serve.bench_collections.load()["dear"]["ad_hoc"] is True, made
|
|
509
|
+
made = call("/api/collection", {"name": "dear", "members": ["m1"]}, on=dash.server_port)
|
|
510
|
+
assert json.loads(made[2])["ok"] and "bench/dear" in ok("/v1/models")[0], made # unticked: on offer again
|
|
511
|
+
serve.delete_collection("dear")
|
|
512
|
+
|
|
513
|
+
# A COLLECTION'S OWN LIMITS: above the gateway's 3 and 0 here, checked, undone by null.
|
|
514
|
+
def save(name, members, **own):
|
|
515
|
+
made = call("/api/collection", {"name": name, "members": members, "shape": "general", **own}, on=dash.server_port)
|
|
516
|
+
return json.loads(made[2])
|
|
517
|
+
def ask_it(name):
|
|
518
|
+
return json.loads(ok("/v1/chat/completions", {"model": f"bench/{name}", "messages": [
|
|
519
|
+
{"role": "user", "content": "x"}], "stream": False})[0])
|
|
520
|
+
for bad in ({"max_calls": 11}, {"max_calls": 0}, {"max_explorers": 3}, {"max_calls": "4"}, {"max_explorers": True}):
|
|
521
|
+
assert not save("own", ["m1"], **bad)["ok"], bad
|
|
522
|
+
assert not save("own", ["m1", "m2", "mg"], max_calls=2)["ok"] # its own cap, not the gateway's
|
|
523
|
+
assert save("wide", ["m1", "m2", "mg", "boom"], max_calls=4)["ok"] # above the gateway's
|
|
524
|
+
assert "wide" in serve.bench_collections.load() and not serve.save_gateway_settings(1, 0)["errors"][0].count("wide")
|
|
525
|
+
assert save("own", ["m1", "m2"], max_calls=4, max_explorers=1)["ok"]
|
|
526
|
+
listed_own = [m for m in json.loads(ok("/v1/models")[0])["data"] if m["id"] == "bench/own"][0]
|
|
527
|
+
assert listed_own["explorers"] == 1 and listed_own["max_calls"] == 4, listed_own
|
|
528
|
+
# (boom held throughout: the stub raises for it, and an explorer seat could pick it.)
|
|
529
|
+
real_refused = gateway.refused_now
|
|
530
|
+
gateway.refused_now = lambda aliases, *_: {a: "out of quota" for a in aliases if a == "boom"}
|
|
531
|
+
assert len(ask_it("own")["bench"]["seats"]) == 3 # two members, its own explorer
|
|
532
|
+
# STAND-INS: a held member stays on the collection, named, with a seat in its place.
|
|
533
|
+
gateway.refused_now = lambda aliases, *_: {a: "out of quota" for a in aliases if a in ("m1", "boom")}
|
|
534
|
+
b = ask_it("own")["bench"]
|
|
535
|
+
by_seat = {s["seat"]: s["alias"] for s in b["seats"]}
|
|
536
|
+
assert [k["alias"] for k in b["skipped"]] == ["m1"] and "m1" not in by_seat.values(), b
|
|
537
|
+
assert len(b["seats"]) == 3 and "stand-in for m1" in by_seat and by_seat["stand-in for m1"] != "m2", b
|
|
538
|
+
assert "m1" in serve.bench_collections.load()["own"]["members"]
|
|
539
|
+
# Max explorers 0: only the members, here and when the gateway's own 0 applies.
|
|
540
|
+
assert save("own", ["m1", "m2"], max_calls=4, max_explorers=0)["ok"]
|
|
541
|
+
assert [s["alias"] for s in ask_it("own")["bench"]["seats"]] == ["m2"]
|
|
542
|
+
assert save("own", ["m1", "m2"], max_calls=None, max_explorers=None)["ok"]
|
|
543
|
+
assert "max_calls" not in serve.bench_collections.load()["own"]
|
|
544
|
+
assert [s["alias"] for s in ask_it("own")["bench"]["seats"]] == ["m2"]
|
|
545
|
+
gateway.refused_now = lambda aliases, *_: {a: "out of quota" for a in aliases}
|
|
546
|
+
assert call("/v1/chat/completions", {"model": "bench/own", "messages": [{"role": "user", "content": "x"}]})[0] == 429
|
|
547
|
+
gateway.refused_now = real_refused
|
|
548
|
+
# A connection the alerts say has stopped answering counts as held.
|
|
549
|
+
import alerts
|
|
550
|
+
real_active, alerts.active = alerts.active, lambda: {"c": {"kind": "connection", "message": "not answering", "aliases": ["m1"]}}
|
|
551
|
+
assert gateway.refused_now(["m1", "m2"], 10, 1) == {"m1": "not answering"}
|
|
552
|
+
alerts.active = real_active
|
|
553
|
+
serve.delete_collection("own")
|
|
554
|
+
serve.delete_collection("wide")
|
|
555
|
+
seats.asked.clear()
|
|
496
556
|
# Token counts are estimates, without a model call; an image counts IMAGE_TOKENS.
|
|
497
557
|
counted = {"model": "bench/core", "messages": [
|
|
498
558
|
{"role": "user", "content": [{"type": "text", "text": "x" * 400},
|
|
@@ -514,6 +514,25 @@ def main() -> int:
|
|
|
514
514
|
print(("ok " if ok else "FAIL ") + "provider chip names" + ("" if ok else f" -> {got}"))
|
|
515
515
|
failures += 0 if ok else 1
|
|
516
516
|
|
|
517
|
+
# An answer clears every alert it disproves (2026-11-02: big-pickle answered a
|
|
518
|
+
# Retry and stayed listed, its unrecognised-failure alert open), never a price;
|
|
519
|
+
# a held connection loses the model, and goes with its last one.
|
|
520
|
+
alerts.raise_alert("model:unclassified:t/served", "registration", "t fails oddly")
|
|
521
|
+
alerts.raise_alert("model:pending:t/served", "retiring", "t retires", alias="t")
|
|
522
|
+
alerts.raise_alert("model:paid:t/served", "went paid", "t has a price", alias="t")
|
|
523
|
+
alerts.raise_alert("model:pending:t/served-too", "retiring", "another retires", alias="t2")
|
|
524
|
+
alerts.raise_alert("connection:held:x", "connection", "all fail", aliases=["t", "t2"])
|
|
525
|
+
alerts.raise_alert("connection:held:y", "connection", "all fail", aliases=["t"])
|
|
526
|
+
cleared = alerts.answered("t", "t/served")
|
|
527
|
+
left = alerts.active()
|
|
528
|
+
ok = (set(cleared) == {"model:unclassified:t/served", "model:pending:t/served", "connection:held:y"}
|
|
529
|
+
and "model:paid:t/served" in left and "model:pending:t/served-too" in left
|
|
530
|
+
and left["connection:held:x"]["aliases"] == ["t2"])
|
|
531
|
+
for k in ("model:paid:t/served", "model:pending:t/served-too", "connection:held:x"):
|
|
532
|
+
alerts.clear(k)
|
|
533
|
+
print(("ok " if ok else "FAIL ") + "an answer clears what it disproves, not a price" + ("" if ok else f" -> {cleared}"))
|
|
534
|
+
failures += 0 if ok else 1
|
|
535
|
+
|
|
517
536
|
# Got it clears a notice (NVIDIA changed its figures), never a state still wrong.
|
|
518
537
|
alerts.raise_alert("test:notice", "limits", "a notice", dismiss=True)
|
|
519
538
|
alerts.raise_alert("test:state", "limits", "a state")
|