@wendellresearch-ai/benchwarmers 0.4.0 → 0.5.1

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/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/pacing.py CHANGED
@@ -177,6 +177,28 @@ def _ago(s: float) -> str:
177
177
  return "just now" if s < 60 else f"{s / 60:.0f} min ago" if s < 7200 else f"{s / 3600:.0f} h ago"
178
178
 
179
179
 
180
+ def _since(rs: list[dict], records: list[dict]) -> float:
181
+ """The oldest ledger line any of these windows or quota records counts."""
182
+ spans = [AXES[r["axis"]][1] for r in rs]
183
+ return min([ledger.month_start(time.time()) if s == "month" else time.time() - s for s in spans]
184
+ + [_ts(q["observed_at"]) for q in records if q.get("observed_at")])
185
+
186
+
187
+ def held(alias: str, connection: str, est_tokens: int = 1) -> tuple[float, str] | None:
188
+ """(seconds until there is room, why) when a call to `alias` would have to
189
+ wait or be refused now, else None. Read only: no slot is taken. For
190
+ picking seats, so a model the bench knows will be refused is not seated
191
+ (gateway.refused_now); `admit` still decides each call."""
192
+ import quota
193
+ rs, records = rules(alias, connection), quota.live(connection, alias)
194
+ if not rs and not records:
195
+ return None
196
+ now = time.time()
197
+ block = _blocked(rs, records, alias, connection, est_tokens, now,
198
+ list(ledger.rows(_since(rs, records))), _inflight(now))
199
+ return None if block is None else (max(block[0] - now, 0.0), block[1])
200
+
201
+
180
202
  def admit(alias: str, connection: str, est_tokens: int, left: float, sleep=time.sleep) -> tuple[str | None, str]:
181
203
  """Wait for room, or refuse. Returns (ticket, refusal): a ticket to pass
182
204
  to `release` once the call's ledger line is written (None when no limit
@@ -189,9 +211,7 @@ def admit(alias: str, connection: str, est_tokens: int, left: float, sleep=time.
189
211
  records = quota.live(connection, alias)
190
212
  if not rs and not records:
191
213
  return None, ""
192
- spans = [AXES[r["axis"]][1] for r in rs]
193
- since = min([ledger.month_start(time.time()) if s == "month" else time.time() - s for s in spans]
194
- + [_ts(q["observed_at"]) for q in records if q.get("observed_at")])
214
+ since = _since(rs, records)
195
215
  with locked.lock(LOCK):
196
216
  now = time.time()
197
217
  rows = list(ledger.rows(since))
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"},