@wendellresearch-ai/benchwarmers 0.2.1 → 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.
@@ -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 — `--with` is the
80
- only way onto the panel. Staleness would otherwise pick it first, since it is
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.2.1",
3
+ "version": "0.2.2",
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, not tokens.** The `llm` CLI does not report token
24
- counts for these endpoints. Chars/s is a proxy, it is affected by how
25
- verbose a model is, and calling it a proxy is more useful than quietly
26
- publishing a number that looks like tokens/s and is not.
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 = ask_once(binary, model, prompt, timeout)
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,7 @@ 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),
55
58
  ("scripts/test_serve_edit.py", [], False),
56
59
  ("scripts/test_connect.py", [], False),
57
60
  ("scripts/test_auth.py", [], False),
@@ -61,6 +64,8 @@ CHECKS = [
61
64
  ("scripts/test_mcp.py", [], False),
62
65
  ("scripts/test_cli.py", [], False),
63
66
  ("scripts/test_launcher.py", [], False),
67
+ ("scripts/test_server_json.py", [], False),
68
+ ("scripts/test_quota.py", [], False),
64
69
  ("suite/test_score.py", [], False),
65
70
  ("suite/test_mine.py", [], False),
66
71
  ("suite/test_seed.py", [], False),
@@ -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.
@@ -14,7 +14,14 @@ For every scenario both backends go through suite/run.py's ask_once and must
14
14
  land in the same place: the same `outcome` and `failure_class`, at the suite's
15
15
  length floor and the perf tier's, and the same text when the model answered.
16
16
  That is what Phase 2a promises before the client may become the default. The
17
- requests are compared too: model, messages, options, key and headers.
17
+ requests are compared too: model, messages, options, key and headers, and
18
+ that both ask for a stream (Phase 2b). Then the built-in client's own: the
19
+ usage from the stream's last chunk and its ledger line, and a call refused
20
+ by pacing, which must land where llm's real 429 does.
21
+
22
+ The data folder is a temp one too (BENCHWARMERS_HOME, set before any module
23
+ reads it): the built-in client now writes a ledger line and quota records
24
+ for every call.
18
25
  """
19
26
 
20
27
  from __future__ import annotations
@@ -29,10 +36,13 @@ import threading
29
36
  import time
30
37
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31
38
 
39
+ DATA = pathlib.Path(tempfile.mkdtemp(prefix="bench-client-data-"))
40
+ os.environ["BENCHWARMERS_HOME"] = str(DATA)
32
41
  ROOT = pathlib.Path(__file__).resolve().parent.parent
33
42
  sys.path[:0] = [str(ROOT / "metrics"), str(ROOT / "suite")]
34
43
  import client # noqa: E402
35
44
  import endpoints # noqa: E402
45
+ import ledger # noqa: E402
36
46
  import run # noqa: E402
37
47
 
38
48
  ANSWER = "The quick brown fox jumps over the lazy dog, and then does it twice."
@@ -154,7 +164,14 @@ def main() -> int:
154
164
  tmp = pathlib.Path(tempfile.mkdtemp(prefix="bench-client-"))
155
165
  scenarios = ["ok", "short", "empty", "notfound", "ratelimit", "flaky", "html502", "slow", "notice",
156
166
  "refused", "nokey"]
157
- own = ["redirect", "trickle", "cutoff", "nochoices"] # the built-in client only
167
+ own = ["redirect", "trickle", "cutoff", "nochoices", "paced"] # the built-in client only
168
+ # The endpoint the live scenarios are on, so they share a connection ("fake")
169
+ # and pacing has one to limit: one call a minute for the paced model.
170
+ (DATA / "config").mkdir(parents=True, exist_ok=True)
171
+ (DATA / "config" / "endpoints.json").write_text(json.dumps(
172
+ {"fake": {"base": live, "key": "fake", "kind": "provider", "default": True}}), encoding="utf-8")
173
+ (DATA / "config" / "model-overrides.json").write_text('{"t-paced": {"limits": {"rpm": 1}}}',
174
+ encoding="utf-8")
158
175
  yaml = []
159
176
  for s in scenarios + own:
160
177
  yaml += [f"- model_id: t-{s}", f" model_name: {'ok' if s in ('refused', 'nokey') else s}",
@@ -171,6 +188,11 @@ def main() -> int:
171
188
  tmp / "options.json")
172
189
 
173
190
  binary, failures, prompt = run.llm_binary(), 0, "Say something."
191
+
192
+ def kinds(x):
193
+ return ((run.outcome(x) or ("answered",))[0], (run.outcome(x, 1) or ("answered",))[0],
194
+ run.failure_class(x), run.failure_class(x, 1))
195
+ bodies_429 = {}
174
196
  for s in scenarios:
175
197
  bodies = {}
176
198
  for backend in ("llm", "builtin"):
@@ -179,10 +201,8 @@ def main() -> int:
179
201
  # llm needed 19-41 s for "refused" there, past a 30 s deadline.
180
202
  bodies[backend], _ = run.ask_once(binary, f"t-{s}", prompt, {"slow": 4, "refused": 90}.get(s, 30))
181
203
  a, b = bodies["llm"], bodies["builtin"]
182
-
183
- def kinds(x):
184
- return ((run.outcome(x) or ("answered",))[0], (run.outcome(x, 1) or ("answered",))[0],
185
- run.failure_class(x), run.failure_class(x, 1))
204
+ if s == "ratelimit":
205
+ bodies_429 = bodies
186
206
  same = kinds(a) == kinds(b) and (a == b or kinds(a)[1] != "answered")
187
207
  print(("ok " if same else "FAIL ") + f"{s:<10} {kinds(b)}")
188
208
  if not same:
@@ -199,12 +219,36 @@ def main() -> int:
199
219
  print(("ok " if ok else "FAIL ") + f"request matches: {got}, {hdr[1]}"
200
220
  + ("" if ok else f"\n llm sent: {want}, {hdr[0]}"))
201
221
  failures += not ok
222
+ # Both stream (2b), as llm always has; the built-in client asks for usage in the stream.
223
+ streams = [sent[w]["body"].get("stream") for w in ("llm", "builtin")]
224
+ ok = streams == [True, True] and sent["builtin"]["body"].get("stream_options") == {"include_usage": True}
225
+ print(("ok " if ok else "FAIL ") + f"both ask for a stream (llm {streams[0]}, built-in {streams[1]},"
226
+ f" usage {sent['builtin']['body'].get('stream_options')})")
227
+ failures += not ok
202
228
  # Retries as the SDK does them: three tries at a 429 that never clears.
203
229
  tries = [len(seen.get((w, "ratelimit"), [])) for w in ("llm", "builtin")]
204
230
  ok = tries == [3, 3]
205
231
  print(("ok " if ok else "FAIL ") + f"429 tried {tries[1]} times (llm {tries[0]})")
206
232
  failures += not ok
207
233
 
234
+ # Usage from the stream's last chunk, in the result and in the ledger.
235
+ r = client.chat("t-ok", prompt, timeout=30)
236
+ line = [x for x in ledger.rows() if x["alias"] == "t-ok"][-1]
237
+ ok = (r["usage"].get("completion_tokens") == 5 and line["tokens_in"] == 3 and line["tokens_out"] == 5
238
+ and line["status"] == "ok" and line["connection"] == "fake")
239
+ print(("ok " if ok else "FAIL ") + f"usage from the stream and its ledger line: {r['usage']}, {line}")
240
+ failures += not ok
241
+ # Pacing: the model's own rpm 1; the second call in the minute is not sent, and
242
+ # lands where llm's real 429 lands ("ratelimit" above), at both length floors.
243
+ first = client.chat("t-paced", prompt, timeout=30)
244
+ body = run.ask_once(binary, "t-paced", prompt, 30)[0]
245
+ want = kinds(bodies_429["llm"])
246
+ ok = not first["error"] and "not sent" in body and kinds(body) == want \
247
+ and len(seen.get(("builtin", "paced"), [])) == 1
248
+ print(("ok " if ok else "FAIL ") + f"a paced refusal lands where a real 429 does: {kinds(body)}"
249
+ + ("" if ok else f"\n llm's 429: {want}; refusal: {body[:150]!r}"))
250
+ failures += not ok
251
+
208
252
  # Where the built-in client is stricter than urllib's defaults would be.
209
253
  r = client.chat("t-redirect", prompt, timeout=10)
210
254
  ok = r["status"] == 302 and "not followed" in r["error"] and not any(leaked)