@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2
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 +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
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()
|