@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.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/AGENTS.md +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +170 -44
- package/ui/bench.js +6 -3
package/metrics/quota.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Quota records: what a provider says is left on a connection, and when it resets.
|
|
2
|
+
|
|
3
|
+
python metrics/quota.py # every record still in force
|
|
4
|
+
python metrics/quota.py --selftest # each reset format and provider profile; no calls
|
|
5
|
+
|
|
6
|
+
RELEASE-PLAN section 2, "Quotas". The built-in client (metrics/client.py)
|
|
7
|
+
hands every response's headers here, and every 429's body; what they state
|
|
8
|
+
becomes one record per connection, model, metric and window:
|
|
9
|
+
|
|
10
|
+
{"connection": "groq", "alias": "...", "metric": "requests", "window": "day",
|
|
11
|
+
"limit": 1000, "remaining": 998, "reset_at": "2026-10-24T00:00:12+00:00",
|
|
12
|
+
"source": "headers (groq)", "observed_at": "2026-10-23T19:01:30+00:00"}
|
|
13
|
+
|
|
14
|
+
kept in runs/quotas.json (not config: it expires, and is nobody's decision),
|
|
15
|
+
by connection and then by model, since Groq, Cerebras and OpenAI meter each
|
|
16
|
+
model apart. Pacing (metrics/pacing.py) reads them back: calls sent since a
|
|
17
|
+
record was observed are taken off its `remaining`, and when nothing is left
|
|
18
|
+
the next call waits for `reset_at` or is refused. A record stops counting at
|
|
19
|
+
its reset, so a connection is never held for want of traffic to observe one.
|
|
20
|
+
|
|
21
|
+
ONE HEADER NAME, DIFFERENT WINDOWS
|
|
22
|
+
----------------------------------
|
|
23
|
+
`x-ratelimit-limit-requests` is per minute at OpenAI and per day at Groq;
|
|
24
|
+
Cerebras names its windows (`-requests-day`, `-tokens-minute`); OpenRouter
|
|
25
|
+
sends bare `X-RateLimit-*` only on a 429, inside the body's metadata as well,
|
|
26
|
+
and its message says which window ("free-models-per-day"); Anthropic's
|
|
27
|
+
`anthropic-ratelimit-*` are per minute; Gemini sends no headers but its 429
|
|
28
|
+
body names the exhausted quota and the retry delay. PROFILES holds what each
|
|
29
|
+
provider means, picked by the endpoint's host; any other host is read the
|
|
30
|
+
OpenAI way with the window left "unknown", which still enforces, since the
|
|
31
|
+
reset is absolute.
|
|
32
|
+
|
|
33
|
+
RESETS COME IN FIVE SPELLINGS
|
|
34
|
+
-----------------------------
|
|
35
|
+
Durations (`6m0s`, `2m59.56s`, `20ms`, `17s`), float seconds (Cerebras'
|
|
36
|
+
`33011.38`), epoch seconds or milliseconds (OpenRouter's `1761264000000`),
|
|
37
|
+
RFC 3339 (Anthropic) and HTTP dates (Retry-After). Each becomes an absolute
|
|
38
|
+
time the moment the response arrives: "in 6m0s" means nothing to whoever
|
|
39
|
+
reads the record later.
|
|
40
|
+
|
|
41
|
+
metrics/learn.py still learns from the error TEXT, for either backend: a
|
|
42
|
+
stated limit, a context window, a cooldown. A 429 body that states a limit
|
|
43
|
+
with a known window (Gemini's "50 a day") is passed on to it here, so the
|
|
44
|
+
limit is learned (and enforced) like one read from a message.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import datetime as dt
|
|
50
|
+
import email.utils
|
|
51
|
+
import json
|
|
52
|
+
import pathlib
|
|
53
|
+
import re
|
|
54
|
+
import sys
|
|
55
|
+
import time
|
|
56
|
+
import urllib.parse
|
|
57
|
+
|
|
58
|
+
import locked
|
|
59
|
+
|
|
60
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
61
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
62
|
+
FILE = DATA / "runs" / "quotas.json"
|
|
63
|
+
KEEP_S = 7 * 86400 # a record with no reset is shown this long, never enforced
|
|
64
|
+
|
|
65
|
+
# host (or a parent domain of it) -> (profile, window of x-ratelimit-*-requests / -tokens
|
|
66
|
+
# when the header does not name one)
|
|
67
|
+
PROFILES = {
|
|
68
|
+
"api.openai.com": ("openai", {"requests": "minute", "tokens": "minute"}),
|
|
69
|
+
"api.groq.com": ("groq", {"requests": "day", "tokens": "minute"}),
|
|
70
|
+
"api.cerebras.ai": ("cerebras", {}),
|
|
71
|
+
"openrouter.ai": ("openrouter", {}),
|
|
72
|
+
"api.anthropic.com": ("anthropic", {}),
|
|
73
|
+
"api.sambanova.ai": ("sambanova", {"requests": "minute", "tokens": "minute"}),
|
|
74
|
+
"generativelanguage.googleapis.com": ("gemini", {}),
|
|
75
|
+
}
|
|
76
|
+
WINDOWS = {"minute": 60, "hour": 3600, "day": 86400}
|
|
77
|
+
OPENAI_STYLE = re.compile(r"^x-ratelimit-(limit|remaining|reset)-(requests|tokens)(?:-(minute|hour|day))?$")
|
|
78
|
+
BARE = re.compile(r"^x-ratelimit-(limit|remaining|reset)$")
|
|
79
|
+
ANTHROPIC = re.compile(r"^anthropic-ratelimit-(requests|tokens|input-tokens|output-tokens)-(limit|remaining|reset)$")
|
|
80
|
+
DURATION = re.compile(r"^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def profile_for(base: str) -> tuple[str, dict]:
|
|
84
|
+
"""The profile for an endpoint's base URL; ("generic", {}) for any other host."""
|
|
85
|
+
host = (urllib.parse.urlsplit(base or "").hostname or "").lower()
|
|
86
|
+
for known, prof in PROFILES.items():
|
|
87
|
+
if host == known or host.endswith("." + known):
|
|
88
|
+
return prof
|
|
89
|
+
return "generic", {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def reset_time(value, now: float) -> float | None:
|
|
93
|
+
"""An absolute epoch time from any of the five spellings, or None."""
|
|
94
|
+
raw = str(value or "").strip()
|
|
95
|
+
if not raw:
|
|
96
|
+
return None
|
|
97
|
+
if (m := DURATION.match(raw)) and any(m.groups()):
|
|
98
|
+
h, mi, s, ms = (float(g) if g else 0.0 for g in m.groups())
|
|
99
|
+
return now + h * 3600 + mi * 60 + s + ms / 1000
|
|
100
|
+
if re.match(r"^\d{4}-\d{2}-\d{2}T", raw):
|
|
101
|
+
try:
|
|
102
|
+
return dt.datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
|
103
|
+
except ValueError:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
n = float(raw)
|
|
107
|
+
except ValueError:
|
|
108
|
+
try: # an HTTP date
|
|
109
|
+
return email.utils.parsedate_to_datetime(raw).timestamp()
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
return None
|
|
112
|
+
if n >= 1e12: # epoch milliseconds
|
|
113
|
+
return n / 1000
|
|
114
|
+
if n >= 1e9: # epoch seconds
|
|
115
|
+
return n
|
|
116
|
+
return now + n # seconds from now
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _iso(t: float | None) -> str | None:
|
|
120
|
+
return None if t is None else dt.datetime.fromtimestamp(t, dt.timezone.utc).isoformat(timespec="seconds")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _int(v) -> int | None:
|
|
124
|
+
try:
|
|
125
|
+
return int(float(str(v).strip()))
|
|
126
|
+
except (TypeError, ValueError):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _window_hint(text: str) -> str:
|
|
131
|
+
"""OpenRouter says which window ran out in its message, not its headers."""
|
|
132
|
+
low = (text or "").lower()
|
|
133
|
+
for word, window in (("per-day", "day"), ("per day", "day"), ("per-min", "minute"), ("per min", "minute")):
|
|
134
|
+
if word in low:
|
|
135
|
+
return window
|
|
136
|
+
return "unknown"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def from_headers(headers, profile: tuple[str, dict], now: float, status: int = 200,
|
|
140
|
+
hint: str = "", source: str = "headers") -> list[dict]:
|
|
141
|
+
"""The records a response's headers state. `hint`: the error message, which
|
|
142
|
+
names OpenRouter's window."""
|
|
143
|
+
name, defaults = profile
|
|
144
|
+
h = {str(k).lower(): v for k, v in (headers.items() if hasattr(headers, "items") else [])}
|
|
145
|
+
fields: dict[tuple[str, str], dict] = {}
|
|
146
|
+
for key, value in h.items():
|
|
147
|
+
if m := OPENAI_STYLE.match(key):
|
|
148
|
+
what, metric, window = m.group(1), m.group(2), m.group(3) or defaults.get(m.group(2), "unknown")
|
|
149
|
+
elif m := BARE.match(key):
|
|
150
|
+
what, metric, window = m.group(1), "requests", _window_hint(hint)
|
|
151
|
+
elif m := ANTHROPIC.match(key):
|
|
152
|
+
metric, what, window = m.group(1), m.group(2), "minute"
|
|
153
|
+
else:
|
|
154
|
+
continue
|
|
155
|
+
fields.setdefault((metric, window), {})[what] = value
|
|
156
|
+
out = []
|
|
157
|
+
for (metric, window), f in fields.items():
|
|
158
|
+
rec = {"metric": metric, "window": window, "limit": _int(f.get("limit")),
|
|
159
|
+
"remaining": _int(f.get("remaining")), "reset_at": _iso(reset_time(f.get("reset"), now)),
|
|
160
|
+
"source": f"{source} ({name})", "observed_at": _iso(now)}
|
|
161
|
+
if rec["limit"] is not None or rec["remaining"] is not None:
|
|
162
|
+
out.append(rec)
|
|
163
|
+
if status == 429:
|
|
164
|
+
wait = None
|
|
165
|
+
for key, scale in (("retry-after-ms", 0.001), ("retry-after", 1.0)):
|
|
166
|
+
if h.get(key):
|
|
167
|
+
raw = str(h[key]).strip()
|
|
168
|
+
try:
|
|
169
|
+
wait = float(raw) * scale
|
|
170
|
+
except ValueError:
|
|
171
|
+
t = reset_time(raw, now)
|
|
172
|
+
wait = None if t is None else t - now
|
|
173
|
+
break
|
|
174
|
+
if wait and wait > 0:
|
|
175
|
+
out.append({"metric": "requests", "window": "retry-after", "limit": None, "remaining": 0,
|
|
176
|
+
"reset_at": _iso(now + wait), "source": f"retry-after ({name})",
|
|
177
|
+
"observed_at": _iso(now)})
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _error_objects(body):
|
|
182
|
+
"""Each `error` dict in a 429 body: an object, or Gemini's one-item list."""
|
|
183
|
+
for item in (body if isinstance(body, list) else [body]):
|
|
184
|
+
if isinstance(item, dict) and isinstance(item.get("error"), dict):
|
|
185
|
+
yield item["error"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def from_body(body, profile: tuple[str, dict], now: float) -> list[dict]:
|
|
189
|
+
"""The records a 429 body states: Gemini's QuotaFailure and RetryInfo, and
|
|
190
|
+
OpenRouter's copy of its rate-limit headers in `metadata.headers`."""
|
|
191
|
+
out = []
|
|
192
|
+
for err in _error_objects(body):
|
|
193
|
+
meta = err.get("metadata") if isinstance(err.get("metadata"), dict) else {}
|
|
194
|
+
if isinstance(meta.get("headers"), dict):
|
|
195
|
+
out += from_headers(meta["headers"], profile, now, hint=str(err.get("message") or ""),
|
|
196
|
+
source="429 body")
|
|
197
|
+
details = [d for d in err.get("details") or [] if isinstance(d, dict)]
|
|
198
|
+
delay = next((reset_time(d.get("retryDelay"), now) for d in details if d.get("retryDelay")), None)
|
|
199
|
+
for d in details:
|
|
200
|
+
for v in d.get("violations") or []:
|
|
201
|
+
if not isinstance(v, dict) or not v.get("quotaId"):
|
|
202
|
+
continue
|
|
203
|
+
qid = str(v["quotaId"])
|
|
204
|
+
window = next((w for w in ("day", "hour", "minute") if f"per{w}" in qid.lower()), "unknown")
|
|
205
|
+
metric = "tokens" if "token" in (qid + str(v.get("quotaMetric") or "")).lower() else "requests"
|
|
206
|
+
# No retry delay on a per-minute quota: the minute is the wait.
|
|
207
|
+
reset = delay if delay else (now + WINDOWS[window] if window == "minute" else None)
|
|
208
|
+
out.append({"metric": metric, "window": window, "limit": _int(v.get("quotaValue")),
|
|
209
|
+
"remaining": 0, "reset_at": _iso(reset), "quota": qid,
|
|
210
|
+
"source": f"429 body ({profile[0]})", "observed_at": _iso(now)})
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _ts(iso: str | None) -> float | None:
|
|
215
|
+
try:
|
|
216
|
+
return dt.datetime.fromisoformat(iso).timestamp() if iso else None
|
|
217
|
+
except (TypeError, ValueError):
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def store(connection: str, alias: str, records: list[dict], now: float | None = None) -> None:
|
|
222
|
+
"""Keep the newest record per metric and window, and drop what has expired."""
|
|
223
|
+
now = time.time() if now is None else now
|
|
224
|
+
|
|
225
|
+
def change(data: dict) -> None:
|
|
226
|
+
mine = data.setdefault(connection, {}).setdefault(alias, {})
|
|
227
|
+
for r in records:
|
|
228
|
+
mine[f"{r['metric']}/{r['window']}"] = {"connection": connection, "alias": alias, **r}
|
|
229
|
+
for conn in list(data):
|
|
230
|
+
if conn.startswith("_") or not isinstance(data[conn], dict):
|
|
231
|
+
continue
|
|
232
|
+
for a in list(data[conn]):
|
|
233
|
+
recs = data[conn][a]
|
|
234
|
+
for k in list(recs):
|
|
235
|
+
reset, seen = _ts(recs[k].get("reset_at")), _ts(recs[k].get("observed_at")) or 0
|
|
236
|
+
if (reset is not None and reset < now - 86400) or (reset is None and seen < now - KEEP_S):
|
|
237
|
+
del recs[k]
|
|
238
|
+
if not recs:
|
|
239
|
+
del data[conn][a]
|
|
240
|
+
if not data[conn]:
|
|
241
|
+
del data[conn]
|
|
242
|
+
data["_why"] = ("What providers' rate-limit headers and 429 bodies said was left, per connection"
|
|
243
|
+
" and model (metrics/quota.py). Expires; nobody's decision.")
|
|
244
|
+
locked.update(FILE, change, ensure_ascii=False)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def observe(alias: str, connection: str, base: str, status: int, headers, body=None) -> list[dict]:
|
|
248
|
+
"""Record what one response states; returns the records. Called by the
|
|
249
|
+
built-in client on every reply and every HTTP error."""
|
|
250
|
+
now, profile = time.time(), profile_for(base)
|
|
251
|
+
hint = " ".join(str(e.get("message") or "") for e in _error_objects(body)) if status == 429 else ""
|
|
252
|
+
records = from_headers(headers, profile, now, status, hint)
|
|
253
|
+
if status == 429 and body is not None:
|
|
254
|
+
records += from_body(body, profile, now)
|
|
255
|
+
if records:
|
|
256
|
+
store(connection or "?", alias, records, now)
|
|
257
|
+
if status == 429:
|
|
258
|
+
_learn_limits(alias, records)
|
|
259
|
+
return records
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _learn_limits(alias: str, records: list[dict]) -> None:
|
|
263
|
+
"""A 429 that states a per-minute or per-day limit: learned, as learn.py
|
|
264
|
+
learns one stated in an error message (only ever tighter)."""
|
|
265
|
+
axes = {("requests", "minute"): "rpm", ("requests", "day"): "rpd",
|
|
266
|
+
("tokens", "minute"): "tpm", ("tokens", "day"): "tpd"}
|
|
267
|
+
found = {axes[(r["metric"], r["window"])]: r["limit"] for r in records
|
|
268
|
+
if (r["metric"], r["window"]) in axes and isinstance(r.get("limit"), int) and r["limit"] > 0}
|
|
269
|
+
if found:
|
|
270
|
+
try:
|
|
271
|
+
import learn
|
|
272
|
+
learn.note_limits(alias, found, "quota", "; ".join(r["source"] for r in records)[:200])
|
|
273
|
+
except Exception: # noqa: BLE001 -- learning never breaks a call
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def live(connection: str | None = None, alias: str | None = None, now: float | None = None) -> list[dict]:
|
|
278
|
+
"""Records whose reset is still ahead, for one connection and model, or all."""
|
|
279
|
+
now = time.time() if now is None else now
|
|
280
|
+
out = []
|
|
281
|
+
for conn, by_alias in locked.read_json(FILE).items():
|
|
282
|
+
if conn.startswith("_") or not isinstance(by_alias, dict) or (connection and conn != connection):
|
|
283
|
+
continue
|
|
284
|
+
for a, recs in by_alias.items():
|
|
285
|
+
if (alias and a != alias) or not isinstance(recs, dict):
|
|
286
|
+
continue
|
|
287
|
+
out += [r for r in recs.values() if isinstance(r, dict) and (_ts(r.get("reset_at")) or 0) > now]
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _selftest() -> None:
|
|
292
|
+
now = 1_761_264_000.0 # 2025-10-24T00:00:00Z
|
|
293
|
+
# Each reset spelling.
|
|
294
|
+
assert reset_time("6m0s", now) == now + 360
|
|
295
|
+
assert abs(reset_time("2m59.56s", now) - (now + 179.56)) < 1e-6 # Groq
|
|
296
|
+
assert reset_time("20ms", now) == now + 0.02 # OpenAI
|
|
297
|
+
assert reset_time("1h2m3s", now) == now + 3723
|
|
298
|
+
assert reset_time("17s", now) == now + 17 # Gemini's retryDelay
|
|
299
|
+
assert abs(reset_time("33011.382867", now) - (now + 33011.382867)) < 1e-6 # Cerebras
|
|
300
|
+
assert reset_time("1761350400000", now) == 1761350400.0 # OpenRouter, epoch ms
|
|
301
|
+
assert reset_time("1761350400", now) == 1761350400.0 # epoch s
|
|
302
|
+
assert reset_time("2025-10-24T00:01:00Z", now) == now + 60 # Anthropic, RFC 3339
|
|
303
|
+
assert reset_time("Fri, 24 Oct 2025 00:00:30 GMT", now) == now + 30 # an HTTP date
|
|
304
|
+
assert reset_time("", now) is None and reset_time("soon", now) is None
|
|
305
|
+
|
|
306
|
+
def one(recs, metric, window):
|
|
307
|
+
got = [r for r in recs if r["metric"] == metric and r["window"] == window]
|
|
308
|
+
assert len(got) == 1, (metric, window, recs)
|
|
309
|
+
return got[0]
|
|
310
|
+
# OpenAI: unsuffixed means per minute.
|
|
311
|
+
r = from_headers({"x-ratelimit-limit-requests": "500", "x-ratelimit-remaining-requests": "499",
|
|
312
|
+
"x-ratelimit-reset-requests": "120ms", "x-ratelimit-limit-tokens": "30000",
|
|
313
|
+
"x-ratelimit-remaining-tokens": "29000", "x-ratelimit-reset-tokens": "2s"},
|
|
314
|
+
profile_for("https://api.openai.com/v1"), now)
|
|
315
|
+
assert one(r, "requests", "minute")["remaining"] == 499 and one(r, "tokens", "minute")["limit"] == 30000
|
|
316
|
+
# Groq: the same names, but requests are per day.
|
|
317
|
+
r = from_headers({"X-Ratelimit-Limit-Requests": "1000", "X-Ratelimit-Remaining-Requests": "0",
|
|
318
|
+
"X-Ratelimit-Reset-Requests": "2m59.56s"}, profile_for("https://api.groq.com/openai/v1"), now)
|
|
319
|
+
g = one(r, "requests", "day")
|
|
320
|
+
assert g["remaining"] == 0 and g["reset_at"] == _iso(now + 179.56) and g["source"] == "headers (groq)", g
|
|
321
|
+
# Cerebras names its windows.
|
|
322
|
+
r = from_headers({"x-ratelimit-limit-requests-day": "14400", "x-ratelimit-remaining-requests-day": "14399",
|
|
323
|
+
"x-ratelimit-reset-requests-day": "33011.38", "x-ratelimit-limit-tokens-minute": "60000",
|
|
324
|
+
"x-ratelimit-remaining-tokens-minute": "59000", "x-ratelimit-reset-tokens-minute": "11.4"},
|
|
325
|
+
profile_for("https://api.cerebras.ai/v1"), now)
|
|
326
|
+
assert one(r, "requests", "day")["limit"] == 14400 and one(r, "tokens", "minute")["remaining"] == 59000
|
|
327
|
+
# Anthropic: RFC 3339 resets, per minute.
|
|
328
|
+
r = from_headers({"anthropic-ratelimit-requests-limit": "50", "anthropic-ratelimit-requests-remaining": "0",
|
|
329
|
+
"anthropic-ratelimit-requests-reset": "2025-10-24T00:00:45Z"},
|
|
330
|
+
profile_for("https://api.anthropic.com/v1"), now)
|
|
331
|
+
assert one(r, "requests", "minute")["reset_at"] == _iso(now + 45)
|
|
332
|
+
# OpenRouter: bare headers, epoch ms, window from the message; and retry-after on a 429.
|
|
333
|
+
r = from_headers({"X-RateLimit-Limit": "50", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1761350400000",
|
|
334
|
+
"Retry-After": "30"}, profile_for("https://openrouter.ai/api/v1"), now, 429,
|
|
335
|
+
hint="Rate limit exceeded: free-models-per-day")
|
|
336
|
+
assert one(r, "requests", "day")["reset_at"] == _iso(1761350400.0)
|
|
337
|
+
assert one(r, "requests", "retry-after")["reset_at"] == _iso(now + 30)
|
|
338
|
+
# Retry-After is only a quota on a 429.
|
|
339
|
+
assert from_headers({"Retry-After": "30"}, ("generic", {}), now, 503) == []
|
|
340
|
+
# Any other host: read the OpenAI way, window unknown.
|
|
341
|
+
r = from_headers({"x-ratelimit-remaining-requests": "3", "x-ratelimit-reset-requests": "10s"},
|
|
342
|
+
profile_for("http://127.0.0.1:9/v1"), now)
|
|
343
|
+
assert one(r, "requests", "unknown")["source"] == "headers (generic)"
|
|
344
|
+
assert from_headers({"content-type": "application/json"}, ("generic", {}), now) == []
|
|
345
|
+
# Gemini's 429 body (a one-item list through the OpenAI-compatible endpoint).
|
|
346
|
+
gem = [{"error": {"code": 429, "status": "RESOURCE_EXHAUSTED", "details": [
|
|
347
|
+
{"@type": "type.googleapis.com/google.rpc.QuotaFailure", "violations": [
|
|
348
|
+
{"quotaMetric": "generativelanguage.googleapis.com/generate_content_free_tier_requests",
|
|
349
|
+
"quotaId": "GenerateRequestsPerDayPerProjectPerModel-FreeTier", "quotaValue": "50"}]},
|
|
350
|
+
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "17s"}]}}]
|
|
351
|
+
g = one(from_body(gem, profile_for("https://generativelanguage.googleapis.com/v1beta/openai"), now),
|
|
352
|
+
"requests", "day")
|
|
353
|
+
assert g["limit"] == 50 and g["remaining"] == 0 and g["reset_at"] == _iso(now + 17), g
|
|
354
|
+
tok = {"error": {"details": [{"violations": [{"quotaId": "GenerateContentInputTokensPerModelPerMinute-FreeTier",
|
|
355
|
+
"quotaValue": "250000"}]}]}}
|
|
356
|
+
g = one(from_body(tok, ("gemini", {}), now), "tokens", "minute")
|
|
357
|
+
assert g["reset_at"] == _iso(now + 60), g # no delay: the minute is the wait
|
|
358
|
+
# OpenRouter's 429 body carries its headers in metadata.
|
|
359
|
+
orb = {"error": {"message": "Rate limit exceeded: free-models-per-min.", "code": 429,
|
|
360
|
+
"metadata": {"headers": {"X-RateLimit-Limit": "20", "X-RateLimit-Remaining": "0",
|
|
361
|
+
"X-RateLimit-Reset": "1761264060000"}}}}
|
|
362
|
+
g = one(from_body(orb, profile_for("https://openrouter.ai/api/v1"), now), "requests", "minute")
|
|
363
|
+
assert g["limit"] == 20 and g["source"] == "429 body (openrouter)", g
|
|
364
|
+
assert from_body("<html>busy</html>", ("generic", {}), now) == []
|
|
365
|
+
print("ok")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main() -> None:
|
|
369
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
370
|
+
if "--selftest" in sys.argv:
|
|
371
|
+
_selftest()
|
|
372
|
+
return
|
|
373
|
+
recs = live()
|
|
374
|
+
if not recs:
|
|
375
|
+
print(f"no quota record in force ({FILE})")
|
|
376
|
+
return
|
|
377
|
+
for r in sorted(recs, key=lambda r: (r["connection"], r["alias"], r["metric"], r["window"])):
|
|
378
|
+
print(f" {r['connection']:<16} {r['alias']:<30} {r['metric']:<8} {r['window']:<11}"
|
|
379
|
+
f" {r.get('remaining')!s:>7} of {r.get('limit')!s:<7} until {r.get('reset_at')} ({r['source']})")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
if __name__ == "__main__":
|
|
383
|
+
main()
|
package/metrics/route.py
CHANGED
|
@@ -76,9 +76,11 @@ task. A poor score never does. A model that did badly once on one task shape
|
|
|
76
76
|
has earned less confidence, not exile.
|
|
77
77
|
|
|
78
78
|
Cost, separately. A model listed under `ad_hoc` in config/model-roles.json is
|
|
79
|
-
expensive or tightly capped and is never seated automatically
|
|
80
|
-
only way onto
|
|
81
|
-
exactly the model nobody uses casually.
|
|
79
|
+
expensive or tightly capped and is never seated automatically: `--with` is the
|
|
80
|
+
only way onto this router's panel. Staleness would otherwise pick it first,
|
|
81
|
+
since it is exactly the model nobody uses casually. A collection that lists it
|
|
82
|
+
as a member (or merger) still calls it on every request; that is a choice made
|
|
83
|
+
by name, like `--with`.
|
|
82
84
|
"""
|
|
83
85
|
|
|
84
86
|
from __future__ import annotations
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
+
"mcpName": "io.github.WendellResearch-AI/benchwarmers",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"author": "Wendell Research",
|
|
7
8
|
"homepage": "https://github.com/WendellResearch-AI/benchwarmers#readme",
|
package/perf/run.py
CHANGED
|
@@ -20,10 +20,14 @@ WHAT IS RECORDED, AND WHY EACH
|
|
|
20
20
|
queueing, cold start. A large prompt measures THROUGHPUT. A model can be
|
|
21
21
|
fine on one and hopeless on the other, and `nemotron-lightning` (134 s on a
|
|
22
22
|
one-word prompt) is why the distinction earns its place.
|
|
23
|
-
- **Characters per second,
|
|
24
|
-
counts for these endpoints
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
- **Characters per second, and tokens per second where they are real.** The
|
|
24
|
+
`llm` CLI does not report token counts for these endpoints, so chars/s is
|
|
25
|
+
kept as the proxy every row has; it is affected by how verbose a model is.
|
|
26
|
+
The built-in client reads the provider's own usage from the stream (Phase
|
|
27
|
+
2b), and with it `tokens_per_s` is the real thing: output tokens (reasoning
|
|
28
|
+
included, which is generated like any other) over the time from the first
|
|
29
|
+
token to the last, so queueing and connection time are not in it. Blank
|
|
30
|
+
where the provider gave no usage, never estimated.
|
|
27
31
|
- **Outcome counts, split four ways.** answered / refused / error / timeout.
|
|
28
32
|
Collapsing these is the mistake this repo was built around: a safety filter,
|
|
29
33
|
an overloaded service and a slow model are three different facts.
|
|
@@ -76,7 +80,9 @@ FIELDS = ["timestamp", "model", "prompt", "n", "p50_s", "p95_s", "min_s",
|
|
|
76
80
|
# The provider's own words for the worst failure, first 300 chars.
|
|
77
81
|
# Without them `glm-5-3` sat as an unexplained `other` for a month
|
|
78
82
|
# when the message was a plain "404 page not found".
|
|
79
|
-
"detail"
|
|
83
|
+
"detail",
|
|
84
|
+
# Real output tokens/s from the provider's usage (built-in client only).
|
|
85
|
+
"tokens_per_s"]
|
|
80
86
|
|
|
81
87
|
|
|
82
88
|
def percentile(values: list[float], pct: float) -> float:
|
|
@@ -92,14 +98,28 @@ def percentile(values: list[float], pct: float) -> float:
|
|
|
92
98
|
return ordered[k]
|
|
93
99
|
|
|
94
100
|
|
|
101
|
+
def _ask(binary: str, model: str, prompt: str, timeout: int) -> tuple[str, float, float | None]:
|
|
102
|
+
"""ask_once's (text, seconds), plus output tokens/s when the built-in
|
|
103
|
+
client made the call and the provider reported usage."""
|
|
104
|
+
import client
|
|
105
|
+
if client.backend(model) != "builtin":
|
|
106
|
+
return (*ask_once(binary, model, prompt, timeout), None)
|
|
107
|
+
res = client.chat(model, prompt, timeout=timeout, purpose="perf")
|
|
108
|
+
out = (res["usage"] or {}).get("completion_tokens") or (res["usage"] or {}).get("output_tokens")
|
|
109
|
+
# From the first token on, so connection and queueing time are left out.
|
|
110
|
+
span = res["seconds"] - (res.get("first_token_s") or 0)
|
|
111
|
+
rate = out / span if isinstance(out, int) and out > 0 and span > 0 and not res["error"] else None
|
|
112
|
+
return client.output(res, timeout), res["seconds"], rate
|
|
113
|
+
|
|
114
|
+
|
|
95
115
|
def measure(binary: str, model: str, label: str, prompt: str, n: int,
|
|
96
116
|
timeout: int) -> dict:
|
|
97
|
-
latencies, rates = [], []
|
|
117
|
+
latencies, rates, token_rates = [], [], []
|
|
98
118
|
counts = {"answered": 0, "refused": 0, "error": 0, "timeout": 0}
|
|
99
119
|
failures: list[str] = []
|
|
100
120
|
details: dict[str, str] = {}
|
|
101
121
|
for i in range(1, n + 1):
|
|
102
|
-
body, seconds =
|
|
122
|
+
body, seconds, tokens_per_s = _ask(binary, model, prompt, timeout)
|
|
103
123
|
# A one-word reply is the CORRECT answer to the tiny prompt, so
|
|
104
124
|
# only a genuinely empty one counts as a failure here.
|
|
105
125
|
kind = (outcome(body, min_length=1) or ("answered", ""))[0]
|
|
@@ -123,6 +143,8 @@ def measure(binary: str, model: str, label: str, prompt: str, n: int,
|
|
|
123
143
|
# worse than no number.
|
|
124
144
|
if seconds > 0 and label == "large":
|
|
125
145
|
rates.append(len(body) / seconds)
|
|
146
|
+
if tokens_per_s:
|
|
147
|
+
token_rates.append(tokens_per_s)
|
|
126
148
|
print(f" {model} {label} [{i}/{n}] {kind} {seconds:.1f}s", flush=True)
|
|
127
149
|
worst = next((c for c in FAILURE_CLASSES if c in failures), "")
|
|
128
150
|
return {
|
|
@@ -139,6 +161,7 @@ def measure(binary: str, model: str, label: str, prompt: str, n: int,
|
|
|
139
161
|
# is what silently dropped `paywalled` and wrote a blank failure.
|
|
140
162
|
"failure": worst,
|
|
141
163
|
"detail": details.get(worst, ""),
|
|
164
|
+
"tokens_per_s": round(statistics.median(token_rates), 1) if token_rates else "",
|
|
142
165
|
**counts,
|
|
143
166
|
}
|
|
144
167
|
|
package/scripts/check.py
CHANGED
|
@@ -38,10 +38,12 @@ CHECKS = [
|
|
|
38
38
|
("metrics/client.py", ["--selftest"], False),
|
|
39
39
|
("metrics/bench_collections.py", [], False),
|
|
40
40
|
("metrics/learn.py", ["--selftest"], False),
|
|
41
|
+
("metrics/quota.py", ["--selftest"], False),
|
|
41
42
|
("metrics/discover.py", ["--selftest"], False),
|
|
42
43
|
("metrics/test_decide.py", [], False),
|
|
43
44
|
("metrics/wrong.py", [], False),
|
|
44
45
|
("metrics/scheduler.py", ["--selftest"], False),
|
|
46
|
+
("deploy/container.py", ["--selftest"], False),
|
|
45
47
|
("scripts/auto_retire.py", [], False),
|
|
46
48
|
("scripts/onboard.py", ["--selftest"], False),
|
|
47
49
|
("scripts/export_check.py", ["--selftest"], False),
|
|
@@ -52,6 +54,9 @@ CHECKS = [
|
|
|
52
54
|
("scripts/test_verdict.py", [], False),
|
|
53
55
|
("scripts/test_merged.py", [], False),
|
|
54
56
|
("scripts/test_vision.py", [], False),
|
|
57
|
+
("scripts/test_formats.py", [], False),
|
|
58
|
+
("scripts/test_route.py", [], False),
|
|
59
|
+
("scripts/test_native.py", [], False),
|
|
55
60
|
("scripts/test_serve_edit.py", [], False),
|
|
56
61
|
("scripts/test_connect.py", [], False),
|
|
57
62
|
("scripts/test_auth.py", [], False),
|
|
@@ -61,6 +66,8 @@ CHECKS = [
|
|
|
61
66
|
("scripts/test_mcp.py", [], False),
|
|
62
67
|
("scripts/test_cli.py", [], False),
|
|
63
68
|
("scripts/test_launcher.py", [], False),
|
|
69
|
+
("scripts/test_server_json.py", [], False),
|
|
70
|
+
("scripts/test_quota.py", [], False),
|
|
64
71
|
("suite/test_score.py", [], False),
|
|
65
72
|
("suite/test_mine.py", [], False),
|
|
66
73
|
("suite/test_seed.py", [], False),
|
package/scripts/onboard.py
CHANGED
|
@@ -231,7 +231,10 @@ def main() -> None:
|
|
|
231
231
|
ap.add_argument("--endpoint", default=None,
|
|
232
232
|
help="a name from config/endpoints.json; the default endpoint if omitted")
|
|
233
233
|
ap.add_argument("--skip-suite", action="store_true",
|
|
234
|
-
help="register and probe only")
|
|
234
|
+
help="register and probe only; the scheduled suite passes fill the suite in later")
|
|
235
|
+
ap.add_argument("--ad-hoc", action="store_true",
|
|
236
|
+
help="mark it ad hoc as it is registered: the bench never calls it on its own"
|
|
237
|
+
" (for a metered, capped or trial key)")
|
|
235
238
|
args = ap.parse_args()
|
|
236
239
|
|
|
237
240
|
name = args.served_name
|
|
@@ -287,6 +290,12 @@ def main() -> None:
|
|
|
287
290
|
else:
|
|
288
291
|
register(alias, name, endpoint)
|
|
289
292
|
print(f" [2/5] registered as {alias}")
|
|
293
|
+
if args.ad_hoc:
|
|
294
|
+
# Before the probe, so no scheduled run can pick it up in between.
|
|
295
|
+
locked.update(DATA / "config" / "model-roles.json",
|
|
296
|
+
lambda roles: roles.__setitem__("ad_hoc", sorted({*roles.get("ad_hoc", []), alias})),
|
|
297
|
+
ensure_ascii=False)
|
|
298
|
+
print(" ad hoc: the bench never calls it on its own")
|
|
290
299
|
run("scripts/sync_config.py", timeout=300)
|
|
291
300
|
# Open WebUI's tags name the connection behind the hub; without a hub the
|
|
292
301
|
# endpoint is the connection, and route.connection_of needs no tags.
|
package/scripts/test_auth.py
CHANGED
|
@@ -179,7 +179,8 @@ def main() -> int:
|
|
|
179
179
|
assert call(d, "/api/gateway-key", {"name": "other", "collections": ["nope"]}, **jar)[0] == 400
|
|
180
180
|
assert call(d, "/api/gateway-key", {"name": "other", "rpm": "lots"}, **jar)[0] == 400
|
|
181
181
|
status, _, text = call(g, "/v1/models", **estate)
|
|
182
|
-
assert [m["id"] for m in json.loads(text)["data"]] == ["bench/vision", "bench/vision:merged"
|
|
182
|
+
assert [m["id"] for m in json.loads(text)["data"]] == ["bench/vision", "bench/vision:merged",
|
|
183
|
+
"bench/vision:route"], text
|
|
183
184
|
ask = {"messages": [{"role": "user", "content": "hi"}]}
|
|
184
185
|
status, _, text = call(g, "/v1/chat/completions", {"model": "bench/core", **ask}, **estate)
|
|
185
186
|
assert status == 403 and "may call bench/vision" in text, text
|