@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.
@@ -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.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, 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,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),
@@ -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.
@@ -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"], text
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