@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.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -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()