@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.
- package/GATEWAY.md +9 -5
- package/PRIVACY.md +9 -3
- package/README.md +31 -8
- package/USAGE.md +81 -15
- package/VERSION +1 -1
- package/cli.py +17 -5
- package/gateway.py +34 -2
- package/launcher.py +2 -1
- package/metrics/aliases.py +24 -3
- package/metrics/discover.py +68 -18
- package/metrics/endpoints.py +36 -4
- package/metrics/model_roles.py +3 -1
- package/metrics/pacing.py +23 -3
- package/metrics/quotaview.py +460 -0
- package/metrics/route.py +3 -2
- package/metrics/scheduler.py +2 -1
- package/metrics/sources.py +29 -6
- package/metrics/wrong.py +3 -1
- package/package.json +1 -1
- package/scripts/check.py +3 -0
- package/scripts/onboard.py +12 -1
- package/scripts/test_hubs.py +10 -5
- package/scripts/test_no_owui.py +14 -2
- package/scripts/test_quota.py +12 -2
- package/scripts/test_serve_edit.py +58 -5
- package/scripts/test_server_json.py +22 -2
- package/serve.py +570 -128
- package/suite/README.md +33 -0
- package/suite/pace.py +8 -4
- package/suite/run.py +144 -16
- package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
- package/suite/tasks/vision-bars/task.json +40 -0
- package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
- package/suite/tasks/vision-invoice/task.json +40 -0
- package/suite/tasks/vision-label/fixtures/label.png +0 -0
- package/suite/tasks/vision-label/task.json +52 -0
- package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
- package/suite/tasks/vision-receipt/task.json +44 -0
- package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
- package/suite/tasks/vision-screen/task.json +48 -0
- package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
- package/suite/tasks/vision-shapes/task.json +40 -0
- package/suite/tasks/vision-table/fixtures/table.png +0 -0
- package/suite/tasks/vision-table/task.json +40 -0
- package/suite/test_vision_tasks.py +201 -0
- package/suite/vision_keys.py +74 -0
- package/suite/vision_sample.py +232 -0
- package/suite/vision_tasks.py +338 -0
- package/ui/bench.css +71 -6
- package/ui/bench.js +543 -49
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""The Quota page's tiles: per provider, what is left, when it resets, who rests.
|
|
2
|
+
|
|
3
|
+
python metrics/quotaview.py # every tile, as the page gets it
|
|
4
|
+
python metrics/quotaview.py --selftest # the parsers and the tile shapes; no calls
|
|
5
|
+
|
|
6
|
+
One tile per connection tag a bench model arrives through
|
|
7
|
+
(route.full_tag_of: `cli-proxy-api/codex`, `openrouter`, `nvidia-api`).
|
|
8
|
+
A tile takes the shape of what its provider says:
|
|
9
|
+
|
|
10
|
+
groups windows per model group, several each (CLI Proxy API's OAuth
|
|
11
|
+
credentials: Gemini 5 hours and week), or per model from the
|
|
12
|
+
rate-limit headers the built-in client saw (metrics/quota.py)
|
|
13
|
+
pool one allowance the models share: OpenRouter's free requests a
|
|
14
|
+
day, a bar per key, counted from the usage ledger
|
|
15
|
+
models nothing stated: each bench model's last day, answered or not
|
|
16
|
+
|
|
17
|
+
REFRESH NEVER SPENDS QUOTA. Two providers can be asked what is left for free,
|
|
18
|
+
and are, when Refresh is pressed:
|
|
19
|
+
|
|
20
|
+
OpenRouter GET /key on a direct OpenRouter endpoint: free tier or
|
|
21
|
+
not (50 or 1,000 free requests a day), and its credits
|
|
22
|
+
CLI Proxy API its management route `requests/api-call`, which calls the
|
|
23
|
+
credential's own quota endpoint with the credential's
|
|
24
|
+
token, as its console does (Google's retrieveUserQuotaSummary,
|
|
25
|
+
ChatGPT's wham/usage). Needs CLI Proxy API on the
|
|
26
|
+
Endpoints page as a hub with its management key
|
|
27
|
+
(endpoints.admin_key, as metrics/cliproxy.py); the proxy is
|
|
28
|
+
on the LAN and calls only providers the credential uses.
|
|
29
|
+
|
|
30
|
+
Every other provider's Refresh re-reads the bench's own records. `check`
|
|
31
|
+
is the one that asks: a 16-token call to one of its models, whose reply
|
|
32
|
+
headers quota.py records. Offered only where the provider has sent limit
|
|
33
|
+
headers before, since elsewhere the reply would say nothing.
|
|
34
|
+
|
|
35
|
+
Live reads are kept in runs/quota-live.json, newest per tag.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import datetime as dt
|
|
41
|
+
import json
|
|
42
|
+
import pathlib
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.parse
|
|
47
|
+
import urllib.request
|
|
48
|
+
|
|
49
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
50
|
+
sys.path[:0] = [str(ROOT / "metrics"), str(ROOT / "suite")]
|
|
51
|
+
import locked # noqa: E402
|
|
52
|
+
from paths import DATA # noqa: E402
|
|
53
|
+
|
|
54
|
+
LIVE = DATA / "runs" / "quota-live.json"
|
|
55
|
+
UTC = dt.timezone.utc
|
|
56
|
+
CPA_PREFIXES = ("/v8/management", "/v0/management") # the console's, then the older one
|
|
57
|
+
# The headers CLI Proxy API's own console sends to each quota endpoint
|
|
58
|
+
# ($TOKEN$ is replaced by the proxy with the credential's token).
|
|
59
|
+
AG_URLS = ("https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary",
|
|
60
|
+
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary")
|
|
61
|
+
# Google answers 403 without the Antigravity client's User-Agent.
|
|
62
|
+
AG_HEAD = {"Authorization": "Bearer $TOKEN$", "Content-Type": "application/json",
|
|
63
|
+
"User-Agent": "antigravity/hub/2.23.0 darwin/arm64"}
|
|
64
|
+
CODEX_URL = "https://chatgpt.com/backend-api/wham/usage"
|
|
65
|
+
CODEX_HEAD = {"Authorization": "Bearer $TOKEN$", "Content-Type": "application/json",
|
|
66
|
+
"User-Agent": "codex-tui/0.149.1"}
|
|
67
|
+
WINDOW_NAMES = {"5h": "5 hours", "weekly": "week", "daily": "day", "monthly": "month"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _iso(t: float | None) -> str | None:
|
|
71
|
+
return None if t is None else dt.datetime.fromtimestamp(t, UTC).isoformat(timespec="seconds")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ts(iso) -> float | None:
|
|
75
|
+
try:
|
|
76
|
+
return dt.datetime.fromisoformat(str(iso).replace("Z", "+00:00")).timestamp() if iso else None
|
|
77
|
+
except ValueError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _seconds_name(s) -> str:
|
|
82
|
+
"""A window's length as a word: 18000 -> "5 hours", 604800 -> "week"."""
|
|
83
|
+
s = int(s or 0)
|
|
84
|
+
return {3600: "hour", 18000: "5 hours", 86400: "day", 604800: "week", 2592000: "month"}.get(
|
|
85
|
+
s, f"{round(s / 3600)} hours" if s else "window")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _utc_midnight(now: float) -> float:
|
|
89
|
+
d = dt.datetime.fromtimestamp(now, UTC)
|
|
90
|
+
return dt.datetime(d.year, d.month, d.day, tzinfo=UTC).timestamp()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---- parsers: a provider's reply -> groups of windows -----------------------
|
|
94
|
+
|
|
95
|
+
def antigravity_groups(body: dict) -> list[dict]:
|
|
96
|
+
"""retrieveUserQuotaSummary -> [{label, windows: [{label, left, reset_at}]}],
|
|
97
|
+
the shorter window first."""
|
|
98
|
+
out = []
|
|
99
|
+
for g in body.get("groups") or []:
|
|
100
|
+
wins = [{"label": WINDOW_NAMES.get(b.get("window"), str(b.get("window") or "window")),
|
|
101
|
+
"left": round(float(b.get("remainingFraction", 0)) * 100),
|
|
102
|
+
"reset_at": b.get("resetTime")}
|
|
103
|
+
for b in g.get("buckets") or [] if isinstance(b, dict)]
|
|
104
|
+
wins.sort(key=lambda w: ["5 hours", "day", "week", "month"].index(w["label"])
|
|
105
|
+
if w["label"] in ("5 hours", "day", "week", "month") else 9)
|
|
106
|
+
if wins:
|
|
107
|
+
out.append({"label": str(g.get("displayName") or ""), "windows": wins})
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def codex_groups(body: dict) -> list[dict]:
|
|
112
|
+
"""wham/usage -> one group with its primary and secondary windows."""
|
|
113
|
+
rl = body.get("rate_limit") or {}
|
|
114
|
+
wins = [{"label": _seconds_name(w.get("limit_window_seconds")),
|
|
115
|
+
"left": max(0, 100 - round(float(w.get("used_percent") or 0))),
|
|
116
|
+
"reset_at": _iso(w["reset_at"]) if w.get("reset_at") else None}
|
|
117
|
+
for w in (rl.get("primary_window"), rl.get("secondary_window")) if isinstance(w, dict)]
|
|
118
|
+
return [{"label": "", "windows": wins}] if wins else []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---- live reads --------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _get_json(url: str, headers: dict, data: bytes | None = None, timeout: int = 20) -> dict:
|
|
124
|
+
req = urllib.request.Request(url, data=data, headers={"Accept": "application/json", **headers},
|
|
125
|
+
method="POST" if data is not None else "GET")
|
|
126
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
127
|
+
return json.loads(r.read() or b"{}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _openrouter_endpoint() -> str | None:
|
|
131
|
+
"""A direct OpenRouter endpoint with a key: the one /key can be asked on."""
|
|
132
|
+
import endpoints
|
|
133
|
+
for name, entry in endpoints.load().items():
|
|
134
|
+
host = urllib.parse.urlsplit(entry.get("base", "")).hostname or ""
|
|
135
|
+
if host.endswith("openrouter.ai") and not endpoints.hub(name) and endpoints.key(name):
|
|
136
|
+
return name
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_openrouter() -> dict:
|
|
141
|
+
import endpoints
|
|
142
|
+
name = _openrouter_endpoint()
|
|
143
|
+
if not name:
|
|
144
|
+
raise ValueError("no direct OpenRouter endpoint with a key to ask")
|
|
145
|
+
data = _get_json(endpoints.base(name) + "/key", {"Authorization": f"Bearer {endpoints.key(name)}"}).get("data") or {}
|
|
146
|
+
return {"endpoint": name, "free_tier": bool(data.get("is_free_tier")),
|
|
147
|
+
"limit": data.get("limit"), "remaining": data.get("limit_remaining"), "usage": data.get("usage")}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cpa_hub() -> tuple[str, str]:
|
|
151
|
+
"""(management base, key) of the CLI Proxy API hub; ValueError when there is none."""
|
|
152
|
+
import endpoints
|
|
153
|
+
name = endpoints.find_hub("cliproxy")
|
|
154
|
+
if not name:
|
|
155
|
+
raise ValueError("add CLI Proxy API on the Endpoints page (kind hub, CLI Proxy API) with its"
|
|
156
|
+
" management key to read these quotas live")
|
|
157
|
+
secret = endpoints.admin_key(name, fallback=False)
|
|
158
|
+
if not secret:
|
|
159
|
+
raise ValueError(f"CLI Proxy API's management key is not stored: Change key on {name}")
|
|
160
|
+
return endpoints.base(name).removesuffix("/v1"), secret
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _cpa(base: str, secret: str, path: str, body: dict | None = None) -> dict:
|
|
164
|
+
"""One management call; the console's prefix first, the older on a 404.
|
|
165
|
+
A 401 is never retried: the proxy bans an address after 5 refusals."""
|
|
166
|
+
head = {"Authorization": f"Bearer {secret}", "Content-Type": "application/json"}
|
|
167
|
+
for prefix in CPA_PREFIXES:
|
|
168
|
+
try:
|
|
169
|
+
return _get_json(base + prefix + path, head, None if body is None else json.dumps(body).encode())
|
|
170
|
+
except urllib.error.HTTPError as exc:
|
|
171
|
+
if exc.code == 404 and prefix != CPA_PREFIXES[-1]:
|
|
172
|
+
continue
|
|
173
|
+
if exc.code == 401:
|
|
174
|
+
raise ValueError("CLI Proxy API refused the management key; check it before trying again"
|
|
175
|
+
" (5 refusals ban this address for 30 minutes)") from None
|
|
176
|
+
raise ValueError(f"CLI Proxy API answered {exc.code} on {path}") from None
|
|
177
|
+
raise ValueError("CLI Proxy API's management API is off (every route 404s without a secret key)")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _api_call(base, secret, auth_index, method, url, header, data=None) -> dict:
|
|
181
|
+
"""The credential's own request, made by the proxy; the reply's JSON body."""
|
|
182
|
+
reply = _cpa(base, secret, "/requests/api-call",
|
|
183
|
+
{"auth_index": auth_index, "method": method, "url": url, "header": header,
|
|
184
|
+
**({"data": data} if data is not None else {})})
|
|
185
|
+
if not 200 <= int(reply.get("status_code") or 0) < 300:
|
|
186
|
+
raise ValueError(f"{urllib.parse.urlsplit(url).hostname} answered {reply.get('status_code')}")
|
|
187
|
+
body = reply.get("body")
|
|
188
|
+
return json.loads(body) if isinstance(body, str) and body.strip() else (body or {})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_cliproxy(tag: str, sibling_vendors: set[str]) -> dict:
|
|
192
|
+
"""Every enabled credential behind `tag`: `cli-proxy-api/codex` is
|
|
193
|
+
the codex ones, bare `cli-proxy-api` those no sibling tag names."""
|
|
194
|
+
base, secret = _cpa_hub()
|
|
195
|
+
vendor = tag.split("/", 1)[1] if "/" in tag else ""
|
|
196
|
+
creds = [c for c in _cpa(base, secret, "/credentials").get("files") or []
|
|
197
|
+
if not c.get("disabled") and (c.get("provider") == vendor if vendor
|
|
198
|
+
else c.get("provider") not in sibling_vendors)]
|
|
199
|
+
groups, names, plan, notes = [], [], "", []
|
|
200
|
+
for c in creds:
|
|
201
|
+
prov, idx = c.get("provider", ""), c.get("auth_index")
|
|
202
|
+
try:
|
|
203
|
+
if prov == "antigravity":
|
|
204
|
+
body = None
|
|
205
|
+
for url in AG_URLS:
|
|
206
|
+
try:
|
|
207
|
+
body = _api_call(base, secret, idx, "POST", url, AG_HEAD,
|
|
208
|
+
json.dumps({"project": c.get("project_id") or ""}))
|
|
209
|
+
break
|
|
210
|
+
except ValueError:
|
|
211
|
+
continue
|
|
212
|
+
got = antigravity_groups(body or {})
|
|
213
|
+
elif prov == "codex":
|
|
214
|
+
head = dict(CODEX_HEAD)
|
|
215
|
+
acct = (c.get("id_token") or {}).get("chatgpt_account_id")
|
|
216
|
+
if acct:
|
|
217
|
+
head["Chatgpt-Account-Id"] = acct
|
|
218
|
+
body = _api_call(base, secret, idx, "GET", CODEX_URL, head)
|
|
219
|
+
got = codex_groups(body)
|
|
220
|
+
plan = str(body.get("plan_type") or "")
|
|
221
|
+
else:
|
|
222
|
+
notes.append(f"no quota reader for {prov}")
|
|
223
|
+
continue
|
|
224
|
+
except ValueError as exc:
|
|
225
|
+
notes.append(f"{prov}: {exc}")
|
|
226
|
+
continue
|
|
227
|
+
if len(creds) > 1:
|
|
228
|
+
for g in got:
|
|
229
|
+
g["label"] = f"{prov} · {g['label']}".rstrip(" ·")
|
|
230
|
+
groups += got
|
|
231
|
+
names.append(prov)
|
|
232
|
+
if not groups and notes:
|
|
233
|
+
raise ValueError("; ".join(notes))
|
|
234
|
+
return {"groups": groups, "name": ", ".join(dict.fromkeys(names)), "plan": plan, "notes": notes}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def refresh(tag: str) -> dict:
|
|
238
|
+
"""Ask the provider when it can be asked for free, keep the answer;
|
|
239
|
+
{"ok", "how": "live"|"records", "note"}."""
|
|
240
|
+
if tag == "openrouter" and _openrouter_endpoint():
|
|
241
|
+
got = read_openrouter()
|
|
242
|
+
elif tag.split("/")[0] == "cli-proxy-api":
|
|
243
|
+
vendors = {t.split("/", 1)[1] for t in _tags() if t.startswith("cli-proxy-api/")}
|
|
244
|
+
got = read_cliproxy(tag, vendors)
|
|
245
|
+
else:
|
|
246
|
+
return {"ok": True, "how": "records",
|
|
247
|
+
"note": "this provider has no free quota read: shown from the bench's own records"}
|
|
248
|
+
got["at"] = _iso(time.time())
|
|
249
|
+
locked.update(LIVE, lambda d: d.update({tag: got}), ensure_ascii=False)
|
|
250
|
+
return {"ok": True, "how": "live", "note": "; ".join(got.get("notes") or [])}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def check(tag: str) -> dict:
|
|
254
|
+
"""One 16-token call to a model on `tag` that is free to call; the
|
|
255
|
+
built-in client records what its reply headers say (quota.observe)."""
|
|
256
|
+
import client
|
|
257
|
+
import learn
|
|
258
|
+
resting = learn.cooldowns()
|
|
259
|
+
aliases = [a for a in _tags().get(tag, []) if a not in resting]
|
|
260
|
+
if not aliases:
|
|
261
|
+
return {"ok": False, "errors": ["every model on it is resting; nothing was sent"]}
|
|
262
|
+
if not _headers_seen(tag):
|
|
263
|
+
return {"ok": False, "errors": ["this provider has never sent limit headers, so a call would"
|
|
264
|
+
" tell nothing; nothing was sent"]}
|
|
265
|
+
res = client.chat(aliases[0], "Hi", timeout=60, body={"max_tokens": 16}, purpose="quota check", retries=0)
|
|
266
|
+
if res.get("error"):
|
|
267
|
+
return {"ok": False, "errors": [f"{aliases[0]}: {res['error'][:200]}"]}
|
|
268
|
+
return {"ok": True, "note": f"asked {aliases[0]}: one request"}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---- tiles -------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def _bench() -> list[str]:
|
|
274
|
+
from run import _registered_names
|
|
275
|
+
gone = locked.read_json(DATA / "config" / "retired.json")
|
|
276
|
+
return [a for a in _registered_names() if a not in gone]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _tags() -> dict[str, list[str]]:
|
|
280
|
+
import route
|
|
281
|
+
out: dict[str, list[str]] = {}
|
|
282
|
+
for a in _bench():
|
|
283
|
+
tag = route.full_tag_of(a)
|
|
284
|
+
if tag != "?":
|
|
285
|
+
out.setdefault(tag, []).append(a)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _headers_seen(tag: str) -> bool:
|
|
290
|
+
"""Has this tag's provider ever stated a limit in its headers (kept a day past reset)?"""
|
|
291
|
+
import quota
|
|
292
|
+
import route
|
|
293
|
+
aliases = set(_tags().get(tag, []))
|
|
294
|
+
host = route.connection_of(next(iter(aliases), "")) if aliases else tag
|
|
295
|
+
recs = locked.read_json(quota.FILE).get(host) or {}
|
|
296
|
+
return any(str(r.get("source", "")).startswith("headers") and r.get("limit")
|
|
297
|
+
for a, by in recs.items() if a in aliases and isinstance(by, dict)
|
|
298
|
+
for r in by.values() if isinstance(r, dict))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _model_rows(aliases, rows, resting, limits) -> list[dict]:
|
|
302
|
+
out = []
|
|
303
|
+
for a in sorted(aliases):
|
|
304
|
+
mine = [r for r in rows if r.get("alias") == a]
|
|
305
|
+
ok = [r for r in mine if r.get("status") == "ok"]
|
|
306
|
+
out.append({"alias": a, "ok": len(ok), "failed": len(mine) - len(ok),
|
|
307
|
+
"last_ok": _iso(max(r["t"] for r in ok)) if ok else None,
|
|
308
|
+
"last": _iso(max(r["t"] for r in mine)) if mine else None,
|
|
309
|
+
"until": (resting.get(a) or {}).get("until"),
|
|
310
|
+
"limit": limits.get(a, "")})
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _limit_text(lim: dict) -> str:
|
|
315
|
+
words = {"rpm": "requests a minute", "rpd": "requests a day", "tpm": "tokens a minute", "tpd": "tokens a day"}
|
|
316
|
+
return ", ".join(f"{v:,} {words[k]}" for k, v in lim.items() if k in words and v)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def tiles(now: float | None = None) -> list[dict]:
|
|
320
|
+
"""Every tile, in the saved order (out first, then soonest reset, for new ones)."""
|
|
321
|
+
import catalog
|
|
322
|
+
import learn
|
|
323
|
+
import ledger
|
|
324
|
+
import quota
|
|
325
|
+
import route
|
|
326
|
+
now = time.time() if now is None else now
|
|
327
|
+
tags, live = _tags(), locked.read_json(LIVE)
|
|
328
|
+
resting, learned = learn.cooldowns(), learn.learned()
|
|
329
|
+
day_rows = list(ledger.rows(now - 86400))
|
|
330
|
+
utc_rows = [r for r in day_rows if r["t"] >= _utc_midnight(now)]
|
|
331
|
+
entered = catalog.connection_limits()
|
|
332
|
+
regs = __import__("endpoints").registrations()
|
|
333
|
+
out = []
|
|
334
|
+
for tag, aliases in tags.items():
|
|
335
|
+
host = route.connection_of(aliases[0])
|
|
336
|
+
mine = set(aliases)
|
|
337
|
+
limits = {a: _limit_text({k: ev.get("value") for k, ev in ((learned.get(a) or {}).get("limits") or {}).items()})
|
|
338
|
+
for a in aliases}
|
|
339
|
+
limits = {a: t for a, t in limits.items() if t}
|
|
340
|
+
rows = [r for r in day_rows if r.get("alias") in mine]
|
|
341
|
+
t = {"id": tag, "tag": tag, "host": host, "bench": len(aliases), "kind": "models", "groups": [],
|
|
342
|
+
"pool": [], "notes": [], "conn_limit": _limit_text(entered.get(host) or {}),
|
|
343
|
+
"models": _model_rows(aliases, rows, resting, limits),
|
|
344
|
+
"refresh": "records", "check": False, "plan": "", "name": ""}
|
|
345
|
+
got = live.get(tag) or {}
|
|
346
|
+
if tag.split("/")[0] == "cli-proxy-api":
|
|
347
|
+
t["refresh"] = "live"
|
|
348
|
+
if got.get("groups"):
|
|
349
|
+
t.update(kind="groups", groups=got["groups"], name=got.get("name", ""), plan=got.get("plan", ""))
|
|
350
|
+
elif tag == "openrouter":
|
|
351
|
+
t["refresh"] = "live" if _openrouter_endpoint() else "records"
|
|
352
|
+
t["kind"] = "pool"
|
|
353
|
+
t["pool"] = _openrouter_pool(aliases, utc_rows, regs, got, learned, now)
|
|
354
|
+
if t["kind"] == "models": # the headers' windows, when any is in force
|
|
355
|
+
recs = [r for r in quota.live(host, now=now) if r.get("alias") in mine
|
|
356
|
+
and r.get("window") != "retry-after" and r.get("limit")]
|
|
357
|
+
by: dict[str, list] = {}
|
|
358
|
+
for r in recs:
|
|
359
|
+
by.setdefault(r["alias"], []).append(
|
|
360
|
+
{"label": r["metric"] + ("" if r["window"] == "unknown" else " · " + r["window"]),
|
|
361
|
+
"left": round(100 * (r.get("remaining") or 0) / r["limit"]),
|
|
362
|
+
"remaining": r.get("remaining"), "of": r["limit"], "reset_at": r.get("reset_at")})
|
|
363
|
+
if by:
|
|
364
|
+
t["kind"] = "groups"
|
|
365
|
+
t["groups"] = [{"label": a if len(by) > 1 else "", "windows": w} for a, w in sorted(by.items())]
|
|
366
|
+
seen = [_ts(r.get("observed_at")) or 0 for r in recs]
|
|
367
|
+
if seen:
|
|
368
|
+
got = {"at": _iso(max(seen)), "how": "seen"}
|
|
369
|
+
t["check"] = t["refresh"] == "records" and _headers_seen(tag)
|
|
370
|
+
last = max((r["t"] for r in rows if r.get("status") == "ok"), default=None)
|
|
371
|
+
t["fresh"] = ({"how": "live", "at": got["at"]} if got.get("at") and got.get("how") != "seen"
|
|
372
|
+
else {"how": "seen", "at": got["at"]} if got.get("at")
|
|
373
|
+
else {"how": "answered", "at": _iso(last)} if last else {"how": "none", "at": None})
|
|
374
|
+
t["live"] = sum(1 for a in aliases if a not in resting)
|
|
375
|
+
resets = [_ts(w.get("reset_at")) for g in t["groups"] for w in g["windows"]] \
|
|
376
|
+
+ [_ts(p.get("reset_at")) for p in t["pool"]]
|
|
377
|
+
resets = [r for r in resets if r and r > now]
|
|
378
|
+
t["next_reset"] = _iso(min(resets)) if resets else None
|
|
379
|
+
t["out"] = (t["live"] == 0 or any(w["left"] <= 0 for g in t["groups"] for w in g["windows"])
|
|
380
|
+
or any(p.get("left") == 0 for p in t["pool"]))
|
|
381
|
+
t["notes"] = list(got.get("notes") or [])
|
|
382
|
+
out.append(t)
|
|
383
|
+
return ordered(out)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _openrouter_pool(aliases, utc_rows, regs, got, learned, now) -> list[dict]:
|
|
387
|
+
"""A bar per OpenRouter key the bench uses: free-model requests since
|
|
388
|
+
00:00 UTC, the bench's own count, against 50 or 1,000 a day."""
|
|
389
|
+
import endpoints
|
|
390
|
+
import quota
|
|
391
|
+
seen_429 = locked.read_json(quota.FILE).get("openrouter") or {}
|
|
392
|
+
by_ep: dict[str, list[str]] = {}
|
|
393
|
+
for a in aliases:
|
|
394
|
+
by_ep.setdefault(endpoints.endpoint_name(regs.get(a) or {}) or "?", []).append(a)
|
|
395
|
+
bars = []
|
|
396
|
+
for ep, eps in sorted(by_ep.items()):
|
|
397
|
+
used = sum(r.get("attempts") or 1 for r in utc_rows
|
|
398
|
+
if r.get("alias") in eps and str(r.get("served", "")).endswith(":free"))
|
|
399
|
+
# What OpenRouter stated wins (learned, else the last 429's, kept a day
|
|
400
|
+
# past reset); the key's free tier only guesses, and was seen wrong.
|
|
401
|
+
stated = [((learned.get(a) or {}).get("limits") or {}).get("rpd", {}).get("value") for a in eps]
|
|
402
|
+
stated += [r.get("limit") for a in eps for r in (seen_429.get(a) or {}).values()
|
|
403
|
+
if isinstance(r, dict) and r.get("window") in ("day", "unknown")]
|
|
404
|
+
limit = min([s for s in stated if s] or [0]) or None
|
|
405
|
+
if not limit and got.get("endpoint") == ep:
|
|
406
|
+
limit = 50 if got.get("free_tier") else 1000
|
|
407
|
+
via = "direct key" if not endpoints.hub(ep) else f"via {ep}"
|
|
408
|
+
bar = {"label": f"free requests today · {via}", "used": used, "of": limit,
|
|
409
|
+
"left": max(0, round(100 * (limit - used) / limit)) if limit else None,
|
|
410
|
+
"reset_at": _iso(_utc_midnight(now) + 86400), "models": len(eps)}
|
|
411
|
+
bars.append(bar)
|
|
412
|
+
if got.get("limit"):
|
|
413
|
+
lim, rem = float(got["limit"]), float(got.get("remaining") or 0)
|
|
414
|
+
bars.append({"label": "credits", "used": round(lim - rem, 2), "of": lim,
|
|
415
|
+
"left": round(100 * rem / lim) if lim else 0, "reset_at": None, "models": 0})
|
|
416
|
+
return bars
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def ordered(tiles_: list[dict]) -> list[dict]:
|
|
420
|
+
"""The saved order first (config/ui.json quota_order), then the rest: out
|
|
421
|
+
first, then the soonest reset, then by tag."""
|
|
422
|
+
saved = locked.read_json(DATA / "config" / "ui.json").get("quota_order") or []
|
|
423
|
+
pos = {t: i for i, t in enumerate(saved)}
|
|
424
|
+
return sorted(tiles_, key=lambda t: (pos.get(t["id"], len(pos)), not t["out"],
|
|
425
|
+
t["next_reset"] or "9999", t["id"]))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def save_order(order) -> dict:
|
|
429
|
+
if order is not None and not (isinstance(order, list) and all(isinstance(t, str) for t in order)):
|
|
430
|
+
return {"ok": False, "errors": ["order must be a list of tags"]}
|
|
431
|
+
locked.update(DATA / "config" / "ui.json", lambda d: d.update(quota_order=order or []))
|
|
432
|
+
return {"ok": True, "errors": []}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _selftest() -> None:
|
|
436
|
+
ag = {"groups": [{"displayName": "Gemini Models", "buckets": [
|
|
437
|
+
{"window": "weekly", "resetTime": "2026-10-30T23:01:51Z", "remainingFraction": 0.620195},
|
|
438
|
+
{"window": "5h", "resetTime": "2026-10-29T23:11:05Z", "remainingFraction": 0.9362293}]},
|
|
439
|
+
{"displayName": "Gemini 4 Argon", "buckets": [
|
|
440
|
+
{"window": "weekly", "resetTime": "2026-10-31T01:20:30Z", "remainingFraction": 0.40175587}]}]}
|
|
441
|
+
g = antigravity_groups(ag)
|
|
442
|
+
assert [w["label"] for w in g[0]["windows"]] == ["5 hours", "week"], g
|
|
443
|
+
assert [w["left"] for w in g[0]["windows"]] == [94, 62] and g[1]["windows"][0]["left"] == 40, g
|
|
444
|
+
cx = {"plan_type": "free", "rate_limit": {"primary_window": {
|
|
445
|
+
"used_percent": 100, "limit_window_seconds": 2592000, "reset_at": 1795707512}, "secondary_window": None}}
|
|
446
|
+
c = codex_groups(cx)
|
|
447
|
+
assert c == [{"label": "", "windows": [{"label": "month", "left": 0,
|
|
448
|
+
"reset_at": "2026-11-26T15:38:32+00:00"}]}], c
|
|
449
|
+
assert codex_groups({}) == [] and antigravity_groups({}) == []
|
|
450
|
+
assert _seconds_name(18000) == "5 hours" and _seconds_name(7200) == "2 hours"
|
|
451
|
+
assert _utc_midnight(1793306000) == dt.datetime(2026, 10, 29, tzinfo=UTC).timestamp()
|
|
452
|
+
assert _limit_text({"rpm": 20, "tpd": 200000}) == "20 requests a minute, 200,000 tokens a day"
|
|
453
|
+
print("quotaview selftest: ok")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
if "--selftest" in sys.argv:
|
|
458
|
+
_selftest()
|
|
459
|
+
else:
|
|
460
|
+
print(json.dumps(tiles(), indent=1))
|
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
|
-
|
|
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/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/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
|
-
|
|
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
|
+
"version": "0.5.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
|
@@ -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),
|
|
@@ -73,6 +74,8 @@ CHECKS = [
|
|
|
73
74
|
("suite/test_seed.py", [], False),
|
|
74
75
|
("suite/test_rebench.py", [], False),
|
|
75
76
|
("suite/test_pick.py", [], False),
|
|
77
|
+
("suite/test_vision_tasks.py", [], False),
|
|
78
|
+
("suite/vision_keys.py", [], False),
|
|
76
79
|
("scripts/test_client.py", [], True), # runs the llm CLI against a fake server
|
|
77
80
|
]
|
|
78
81
|
|
package/scripts/onboard.py
CHANGED
|
@@ -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)
|