@wendellresearch-ai/benchwarmers 0.5.0 → 0.5.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/PRIVACY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Privacy policy
2
2
 
3
- *Effective 29 October 2026.*
3
+ *Effective 2 November 2026.*
4
4
 
5
5
  Benchwarmers runs on your own machine or server. It has no account, no
6
6
  telemetry and no server of its own, and nothing you do with it reaches
@@ -15,12 +15,19 @@ Wendell Research.
15
15
  applies to what it receives; some free tiers keep prompts or train on them.
16
16
  - **Catalog reads**, carrying nothing of yours: FreeLLMAPI (its feed and its
17
17
  site's sitemap) and models.dev (at most once a day) and OpenRouter's public
18
- model list.
18
+ model list. With an NVIDIA connection on the bench, the public file of rate
19
+ limits build.nvidia.com states, once a day and when you press Refresh on its
20
+ quota tile.
19
21
  - **Sign-ins**, only when you choose one: the provider's own sign-in page, in
20
22
  your browser, and the provider's token endpoint.
21
23
  - **Quota reads**, only when you press Refresh on Quota management, carrying
22
24
  no prompt:
23
25
  - OpenRouter's key status, asked with your key.
26
+ - xKiro's usage, asked with your key for it (from its Open WebUI connection
27
+ when no endpoint has one). Its reply names the account's owner; only the
28
+ quota figures are kept.
29
+ - Requesty's usage for the key and its public model list, asked the same
30
+ way; only the count of free-model requests is kept.
24
31
  - For CLI Proxy API's sign-ins, through the proxy, the quota endpoint of the
25
32
  provider each sign-in belongs to (Google's, ChatGPT's).
26
33
  - **Charts**: the dashboard's page loads Observable Plot from jsDelivr, at a
package/README.md CHANGED
@@ -187,7 +187,8 @@ dependencies either way: the app is the Python standard library.
187
187
  - **Health.** Every morning each model gets a one-word prompt; once a week, a
188
188
  short and a long one for speed. A model that stops answering, or that its
189
189
  endpoint no longer serves, is retired automatically after one confirming
190
- call, with the evidence kept, and can be restored in one click. The
190
+ call, with the evidence kept, and can be restored in one click. A model
191
+ that is only out of quota until a stated reset is not counted against. The
191
192
  built-in scheduler runs all of this once Settings switches it on; a fresh
192
193
  install spends no calls until then.
193
194
  - **Rate limits and usage.** Limits come from providers' rate-limit headers
@@ -196,8 +197,10 @@ dependencies either way: the app is the Python standard library.
196
197
  connection, since models behind one key share them, and enforced: a call
197
198
  waits when the wait fits, or is refused before it goes out. Every call lands
198
199
  in a monthly usage ledger with its tokens. Quota management has a tile per
199
- provider showing what is left, when it resets and who is resting. OpenRouter
200
- and CLI Proxy API's sign-ins are read live, at no cost to quota.
200
+ provider showing what is left, when it resets and who is resting. OpenRouter,
201
+ xKiro, Requesty and CLI Proxy API's sign-ins are read live, at no cost to
202
+ quota. Nothing is taken from a provider's docs; NVIDIA's own stated figures
203
+ are read from its site and watched for changes.
201
204
  - **Discover.** FreeLLMAPI's free catalog, with models.dev for the details
202
205
  and paid models it lacks, merged into one list of models worth adding,
203
206
  smartest first, each with the routes this install has to it.
@@ -438,11 +441,13 @@ reaches Wendell Research. What it sends, and where:
438
441
  are content to send that work to.
439
442
  - **Catalog reads**, no key and nothing of yours: FreeLLMAPI (its feed and
440
443
  its site's sitemap) and models.dev once a day, and OpenRouter's public
441
- model list.
444
+ model list. With an NVIDIA connection, the public file of rate limits
445
+ build.nvidia.com states, once a day and on Refresh.
442
446
  - **Sign-ins**, only when you choose one: the provider's own sign-in page.
443
447
  - **Quota reads**, only when you press Refresh on Quota management, with no
444
- prompt: OpenRouter's key status, with your key. CLI Proxy API asks each
445
- sign-in's own provider for its quota.
448
+ prompt: OpenRouter's key status, and xKiro's and Requesty's usage, each with
449
+ your key for it (from its Open WebUI connection when no endpoint has one). CLI Proxy
450
+ API asks each sign-in's own provider for its quota.
446
451
  - **Charts**: the dashboard's browser page loads Observable Plot from jsDelivr
447
452
  at a pinned version.
448
453
  - **Seeding real-bug tasks**, only when you run `suite/rebench.py`: the
package/USAGE.md CHANGED
@@ -37,8 +37,13 @@ coding agent doing it with you); the gateway's whole contract is in
37
37
 
38
38
  "Action needed", above the pages, lists what will not fix itself, such as a
39
39
  model about to be retired, naming each model and where it comes from, with
40
- Retry, Open and Retire now beside it. Hide folds it away until something new
41
- turns up; an alert whose models have all been retired goes at once.
40
+ Retry, Open and Retire now beside it. It starts folded to its heading and
41
+ count; show opens it in that browser for 12 hours. An alert whose models have
42
+ all been retired goes at once. A model that is out of quota is not counting
43
+ down: days the bench held its calls back until a stated reset, or an account's
44
+ shared free quota was spent, pause its clock, and while it is still out of
45
+ quota no countdown shows. One refused again after the short wait it named is
46
+ failing, and keeps counting.
42
47
 
43
48
  **Restart** (Settings, Server and access), or `bench restart`, starts the
44
49
  dashboard again on the code now on disk, after an update. It waits for
@@ -109,8 +114,11 @@ seats dashed, and the merger after an arrow.
109
114
  In the popup, the panel's seats are on top, and the member list has one row
110
115
  per model with its context, vision and tools, and price. A model served by
111
116
  several providers is still one row, with a chip per provider: tick the chip
112
- of the route you want. Ticking two is allowed, and the count says so. The tags
113
- grey out the models that do not fit them, as before.
117
+ of the route you want. Ticking two is allowed, and the count says so. The
118
+ search matches a model, provider or served name, and the select beside it
119
+ shows one provider's models; ticked ones always stay in view. A seat's ×
120
+ takes that member off, as unticking it does. The tags grey out the models that
121
+ do not fit them.
114
122
 
115
123
  Each call also seats explorers, the models unused longest, up to the gateway's
116
124
  limits (Gateway settings, on the same page: how many calls a request may make,
@@ -231,25 +239,54 @@ app**, switches a provider either way or adds one.
231
239
 
232
240
  ## Quota management
233
241
 
234
- One tile per provider a bench model arrives through, shaped by how that
235
- provider limits:
242
+ One tile per provider a bench model arrives through. Each draws the
243
+ provider's limits at the level they apply, and names a model only where a
244
+ limit is that model's own:
236
245
  - **Windows**: CLI Proxy API's sign-ins have a 5-hour and a weekly limit per
237
- model group. Other providers have the windows their rate-limit headers state.
246
+ model group, a Codex sign-in one for the account. Other providers have the
247
+ windows their replies' headers state; a window past its reset is shown full.
238
248
  - **Pool**: OpenRouter's free requests a day, shared by its models, with a bar
239
- per key.
240
- - **Each model's last day**: shown where the provider states nothing.
241
-
242
- Each tile shows when its limits reset and which of its models are resting
243
- after a rate limit. **Models** lists the provider's bench models, whether each
244
- is free to call now, and its last 24 hours of calls.
245
-
246
- **Refresh never spends quota.** On OpenRouter it asks the key what is left. On
247
- CLI Proxy API's sign-ins it asks the proxy for each one's quota, as its own
248
- console does; this needs CLI Proxy API on the Endpoints page as a hub, with
249
- its management key. Every other tile re-reads what the provider last said in
250
- its replies to the bench. **Check now** sends one 16-token request to read the
251
- reply's limit headers. It is offered only where the provider has sent them
252
- before.
249
+ per key, against the limit OpenRouter stated in a rate-limit reply.
250
+ - **Caps**: a day or month cap, counted from the bench's own calls: one you set
251
+ on Endpoints (Rate limits per connection), else one the provider states in
252
+ its replies without saying what is left (a Cohere trial key's 1,000 calls a
253
+ month), shown but not enforced until you set it. Calls made elsewhere with
254
+ the same key are not in the count.
255
+ - **Nothing stated**: how the provider's calls went in the last 24 hours, all
256
+ its models together (green answered, red failed).
257
+
258
+ Nothing is taken from a provider's docs, which can change without notice; a
259
+ limit a provider states in a rate-limit error (UnoRouter's "allows 1
260
+ request(s) every 1 min" for a model) is learned and kept to. One exception,
261
+ watched: NVIDIA states no limit per key, so its tile reads the file
262
+ build.nvidia.com's own badge reads (10,000 requests a day, up to 40 a minute,
263
+ the same for every account) daily and on Refresh, and counts the bench's
264
+ requests against it. If NVIDIA changes those figures, Action needed says so
265
+ (Got it dismisses it); if the file moves or changes shape, Action needed says
266
+ that too, and the tile keeps the figures it last read. Each meter leads
267
+ with the percentage left, then what is left of what in the provider's own unit
268
+ ("94% left · 189 of 200 requests · day"), with its reset on the right. Under the name, one block says where the figures come
269
+ from (**From**: the provider's quota endpoint or its replies' headers, exact;
270
+ or a stated limit counted by the bench), your per-minute caps, and anything a
271
+ live read reported. **Models** names the connection and how it is reached
272
+ (each endpoint or Open WebUI connection, with its address), then lists the
273
+ provider's bench models (each opens its popup), whether each is free to call
274
+ now, and its last 24 hours of calls.
275
+
276
+ **Refresh** picks its own way, and hovering it says which:
277
+ - **A free read** on OpenRouter (it asks the key what is left), on xKiro (its
278
+ usage endpoint: free-model tokens left today), on Requesty (its usage
279
+ endpoint: the key's free-model requests today, by Requesty's count; its limit
280
+ only once a rate-limit reply has stated it) and on CLI Proxy API's sign-ins
281
+ (it asks the proxy for each one's quota, as its own console does; this needs
282
+ CLI Proxy API on the Endpoints page as a hub, with its management key).
283
+ xKiro's and Requesty's keys come from their endpoint, else their Open WebUI
284
+ connection.
285
+ - **One 16-token request** where a provider states its limits only in its
286
+ replies' headers, to read them (Groq, Routeway, a Cohere trial key). Every
287
+ ordinary call reads them too, through Open WebUI as well, since the bench
288
+ streams.
289
+ - **The bench's own records** again everywhere else.
253
290
 
254
291
  Tiles sort with the ones that are out first, then the soonest reset. Drag a
255
292
  tile, or move it with the arrow keys, and the order is kept; **Sort** brings
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.0
1
+ 0.5.2
@@ -283,6 +283,11 @@ def refresh() -> dict:
283
283
  "refreshed": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
284
284
  "models": models}
285
285
  CATALOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
286
+ try: # NVIDIA's stated limits, watched daily (an alert on a change)
287
+ import quotaview
288
+ quotaview.watch_nvidia()
289
+ except Exception: # noqa: BLE001
290
+ pass
286
291
  return data
287
292
 
288
293
 
package/metrics/learn.py CHANGED
@@ -60,9 +60,17 @@ RETRY = [
60
60
  r"\"retry_?delay\"\s*:\s*\"(\d+(?:\.\d+)?)s",
61
61
  r"\"retry_after\"\s*:\s*(\d+(?:\.\d+)?)",
62
62
  r"(?:try|retry) again in\s*(\d+(?:\.\d+)?)\s*(s|sec|seconds?|m|min|minutes?|h|hours?)\b",
63
- r"please retry in\s*(\d+(?:\.\d+)?)\s*(s|m|h)",
63
+ r"\bretry in\s*(\d+(?:\.\d+)?)\s*(s|m|h)", # "please retry in 2s", UnoRouter's "retry in 60s"
64
64
  ]
65
65
  RATE_LIMITED = re.compile(r"rate.?limit|too many requests|\b429\b|quota exceeded|resource_exhausted")
66
+ # The number and its window in one phrase: UnoRouter's free tier, "allows 5
67
+ # request(s) every 1 min per account on <model>:free". "every" only: the
68
+ # bench's own refusal says "allows 3 requests per minute" (pacing.py), and a
69
+ # limit the bench made up must never be learned back.
70
+ ALLOWS = re.compile(r"allows?\s+([\d,]+)\s+requests?(?:\(s\))?\s+every\s+(?:1\s+)?(min(?:ute)?|day)\b")
71
+ # new-api gateways (Agnes): "maximum 20 requests in 1 minutes" (its
72
+ # middleware/model-rate-limit.go); a window of 1 minute or 1,440 only.
73
+ MAXIMUM = re.compile(r"maximum\s+([\d,]+)\s+requests?\s+in\s+(1|1440)\s+minutes?\b")
66
74
 
67
75
 
68
76
  def _int(s: str) -> int:
@@ -83,6 +91,10 @@ def parse(text: str) -> dict:
83
91
  named = [axis for axis, pat in AXES.items() if re.search(pat, low)]
84
92
  if len(named) == 1 and (m := re.search(r"\blimit\b[\s:=]*([\d,]+)", low)):
85
93
  out["limits"] = {named[0]: _int(m.group(1))}
94
+ elif m := ALLOWS.search(low):
95
+ out["limits"] = {"rpd" if m.group(2) == "day" else "rpm": _int(m.group(1))}
96
+ elif m := MAXIMUM.search(low):
97
+ out["limits"] = {"rpd" if m.group(2) == "1440" else "rpm": _int(m.group(1))}
86
98
  for pat in RETRY:
87
99
  if m := re.search(pat, low):
88
100
  n = float(m.group(1))
@@ -205,6 +217,18 @@ def _selftest() -> None:
205
217
  assert p["retry_after_s"] == 180 and "limits" not in p, p # no number under a named axis
206
218
  # Two axes named: the number is not filed under either.
207
219
  assert "limits" not in parse("limit 100 (tokens per minute) and requests per minute")
220
+ # UnoRouter's free tier says the number and its window together.
221
+ p = parse("Error code: 429 - {'error': {'message': 'Too many requests. The free tier allows 5 request(s) every"
222
+ " 1 min per account on llama-3.2-11b-vision:free - nothing is used up, retry in 60s.")
223
+ assert p["limits"] == {"rpm": 5} and p["retry_after_s"] == 60 and p["rate_limited"], p
224
+ assert parse("The free tier allows 200 requests every day")["limits"] == {"rpd": 200}
225
+ assert "limits" not in parse("allows 3 requests every 5 min") # a window no axis holds
226
+ assert "limits" not in parse("rate limit (not sent; paced by the bench): x allows 3 requests per minute")
227
+ # A new-api gateway's 429 (Agnes).
228
+ p = parse("Error code: 429 - {'error': {'message': 'You have reached the request limit: maximum 20 requests"
229
+ " in 1 minutes', 'type': 'AgnesAI_error'}}")
230
+ assert p["limits"] == {"rpm": 20} and p["rate_limited"], p
231
+ assert "limits" not in parse("maximum 100 requests in 5 minutes") # a window no axis holds
208
232
  assert parse("model not found") == {"rate_limited": False}
209
233
  assert parse("context length of 8k exceeded").get("context_window") is None # "8" < 512
210
234
  print("ok")
package/metrics/quota.py CHANGED
@@ -21,7 +21,11 @@ its reset, so a connection is never held for want of traffic to observe one.
21
21
  ONE HEADER NAME, DIFFERENT WINDOWS
22
22
  ----------------------------------
23
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
24
+ Cerebras names its windows (`-requests-day`, `-tokens-minute`), Routeway only
25
+ its windows (`X-RateLimit-Remaining-Day`, requests, resets in seconds), and a
26
+ Cohere trial key its minute (`x-trial-endpoint-call-remaining`, no reset) and
27
+ its month's limit with nothing left said (`x-endpoint-monthly-call-limit`:
28
+ kept, never enforced, and quotaview counts the month from the ledger); OpenRouter
25
29
  sends bare `X-RateLimit-*` only on a 429, inside the body's metadata as well,
26
30
  and its message says which window ("free-models-per-day"); Anthropic's
27
31
  `anthropic-ratelimit-*` are per minute; Gemini sends no headers but its 429
@@ -76,16 +80,25 @@ PROFILES = {
76
80
  WINDOWS = {"minute": 60, "hour": 3600, "day": 86400}
77
81
  OPENAI_STYLE = re.compile(r"^x-ratelimit-(limit|remaining|reset)-(requests|tokens)(?:-(minute|hour|day))?$")
78
82
  BARE = re.compile(r"^x-ratelimit-(limit|remaining|reset)$")
83
+ WINDOWED = re.compile(r"^x-ratelimit-(limit|remaining|reset)-(minute|hour|day)$") # Routeway: requests
84
+ COHERE = re.compile(r"^x-trial-endpoint-call-(limit|remaining)$") # a trial key's minute
85
+ COHERE_MONTH = "x-endpoint-monthly-call-limit" # a limit with no remaining: quotaview counts it
79
86
  ANTHROPIC = re.compile(r"^anthropic-ratelimit-(requests|tokens|input-tokens|output-tokens)-(limit|remaining|reset)$")
80
87
  DURATION = re.compile(r"^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$")
81
88
 
82
89
 
83
- def profile_for(base: str) -> tuple[str, dict]:
84
- """The profile for an endpoint's base URL; ("generic", {}) for any other host."""
90
+ def profile_for(base: str, connection: str = "") -> tuple[str, dict]:
91
+ """The profile for an endpoint's base URL, else for the connection (a hub
92
+ such as Open WebUI relays the provider's headers: `groq`, `api.groq.com`);
93
+ ("generic", {}) for any other."""
85
94
  host = (urllib.parse.urlsplit(base or "").hostname or "").lower()
95
+ conn = (connection or "").lower()
86
96
  for known, prof in PROFILES.items():
87
97
  if host == known or host.endswith("." + known):
88
98
  return prof
99
+ for known, prof in PROFILES.items():
100
+ if conn in (known, prof[0]) or conn.endswith("." + known):
101
+ return prof
89
102
  return "generic", {}
90
103
 
91
104
 
@@ -148,8 +161,15 @@ def from_headers(headers, profile: tuple[str, dict], now: float, status: int = 2
148
161
  what, metric, window = m.group(1), m.group(2), m.group(3) or defaults.get(m.group(2), "unknown")
149
162
  elif m := BARE.match(key):
150
163
  what, metric, window = m.group(1), "requests", _window_hint(hint)
164
+ elif m := WINDOWED.match(key):
165
+ what, metric, window = m.group(1), "requests", m.group(2)
151
166
  elif m := ANTHROPIC.match(key):
152
167
  metric, what, window = m.group(1), m.group(2), "minute"
168
+ elif m := COHERE.match(key): # no reset sent: the minute is the wait
169
+ what, metric, window = m.group(1), "requests", "minute"
170
+ fields.setdefault((metric, window), {}).setdefault("reset", "60")
171
+ elif key == COHERE_MONTH: # no remaining, no reset: never enforced (live())
172
+ what, metric, window = "limit", "requests", "month"
153
173
  else:
154
174
  continue
155
175
  fields.setdefault((metric, window), {})[what] = value
@@ -247,7 +267,7 @@ def store(connection: str, alias: str, records: list[dict], now: float | None =
247
267
  def observe(alias: str, connection: str, base: str, status: int, headers, body=None) -> list[dict]:
248
268
  """Record what one response states; returns the records. Called by the
249
269
  built-in client on every reply and every HTTP error."""
250
- now, profile = time.time(), profile_for(base)
270
+ now, profile = time.time(), profile_for(base, connection)
251
271
  hint = " ".join(str(e.get("message") or "") for e in _error_objects(body)) if status == 429 else ""
252
272
  records = from_headers(headers, profile, now, status, hint)
253
273
  if status == 429 and body is not None:
@@ -318,12 +338,29 @@ def _selftest() -> None:
318
338
  "X-Ratelimit-Reset-Requests": "2m59.56s"}, profile_for("https://api.groq.com/openai/v1"), now)
319
339
  g = one(r, "requests", "day")
320
340
  assert g["remaining"] == 0 and g["reset_at"] == _iso(now + 179.56) and g["source"] == "headers (groq)", g
341
+ # Through a hub, the connection names the provider.
342
+ assert profile_for("http://owui.lan/api", "groq")[0] == "groq" == profile_for("http://owui.lan/api", "api.groq.com")[0]
343
+ assert profile_for("http://owui.lan/api", "api.cohere.ai") == ("generic", {})
321
344
  # Cerebras names its windows.
322
345
  r = from_headers({"x-ratelimit-limit-requests-day": "14400", "x-ratelimit-remaining-requests-day": "14399",
323
346
  "x-ratelimit-reset-requests-day": "33011.38", "x-ratelimit-limit-tokens-minute": "60000",
324
347
  "x-ratelimit-remaining-tokens-minute": "59000", "x-ratelimit-reset-tokens-minute": "11.4"},
325
348
  profile_for("https://api.cerebras.ai/v1"), now)
326
349
  assert one(r, "requests", "day")["limit"] == 14400 and one(r, "tokens", "minute")["remaining"] == 59000
350
+ # Routeway names only the window; requests, resets in seconds.
351
+ r = from_headers({"X-RateLimit-Limit-Day": "200", "X-RateLimit-Remaining-Day": "187", "X-RateLimit-Reset-Day": "3600",
352
+ "X-RateLimit-Limit-Minute": "5", "X-RateLimit-Remaining-Minute": "4", "X-RateLimit-Reset-Minute": "42"},
353
+ profile_for("http://owui.lan/api"), now)
354
+ d = one(r, "requests", "day")
355
+ assert d["limit"] == 200 and d["remaining"] == 187 and d["reset_at"] == _iso(now + 3600), d
356
+ assert one(r, "requests", "minute")["remaining"] == 4
357
+ # A Cohere trial key: its minute, with no reset (the minute is the wait); the month has no remaining.
358
+ r = from_headers({"x-trial-endpoint-call-limit": "20", "x-trial-endpoint-call-remaining": "19",
359
+ "x-endpoint-monthly-call-limit": "1000"}, profile_for("http://owui.lan/api"), now)
360
+ c = one(r, "requests", "minute")
361
+ assert c["limit"] == 20 and c["remaining"] == 19 and c["reset_at"] == _iso(now + 60) and len(r) == 2, r
362
+ mo = one(r, "requests", "month")
363
+ assert mo["limit"] == 1000 and mo["remaining"] is None and mo["reset_at"] is None, mo
327
364
  # Anthropic: RFC 3339 resets, per minute.
328
365
  r = from_headers({"anthropic-ratelimit-requests-limit": "50", "anthropic-ratelimit-requests-remaining": "0",
329
366
  "anthropic-ratelimit-requests-reset": "2025-10-24T00:00:45Z"},