@wendellresearch-ai/benchwarmers 0.6.0 → 0.6.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/USAGE.md +10 -2
- package/VERSION +1 -1
- package/metrics/decide.py +31 -5
- package/metrics/endpoints.py +5 -1
- package/metrics/learn.py +264 -10
- package/metrics/pacing.py +8 -1
- package/metrics/quotaview.py +19 -5
- package/metrics/test_decide.py +25 -1
- package/package.json +1 -1
- package/scripts/onboard.py +7 -1
- package/scripts/test_quota.py +27 -0
- package/serve.py +49 -4
- package/ui/bench.js +23 -0
package/USAGE.md
CHANGED
|
@@ -314,7 +314,8 @@ limit is that model's own:
|
|
|
314
314
|
- **Pool**: OpenRouter's free requests a day, shared by its models, with a bar
|
|
315
315
|
per key, against the limit OpenRouter stated in a rate-limit reply.
|
|
316
316
|
- **Caps**: a day or month cap, counted from the bench's own calls: one you set
|
|
317
|
-
on Endpoints (Rate limits per connection), else
|
|
317
|
+
on Endpoints (Rate limits per connection), else a day cap the bench learned
|
|
318
|
+
for the connection (below), else one the provider states in
|
|
318
319
|
its replies without saying what is left (a Cohere trial key's 1,000 calls a
|
|
319
320
|
month), shown but not enforced until you set it. Calls made elsewhere with
|
|
320
321
|
the same key are not in the count.
|
|
@@ -323,7 +324,14 @@ limit is that model's own:
|
|
|
323
324
|
|
|
324
325
|
Nothing is taken from a provider's docs, which can change without notice; a
|
|
325
326
|
limit a provider states in a rate-limit error (UnoRouter's "allows 1
|
|
326
|
-
request(s) every 1 min" for a model) is learned and kept to.
|
|
327
|
+
request(s) every 1 min" for a model) is learned and kept to. When the error
|
|
328
|
+
names no number (Cloudflare Workers AI's, once the day's free Neurons are
|
|
329
|
+
gone), the requests the bench had sent on that connection in that minute or
|
|
330
|
+
day are taken as the connection's limit: noted the first time, kept to once a
|
|
331
|
+
second refusal lands near the same count, unless you set your own for that
|
|
332
|
+
connection on Endpoints, where each learned limit is shown with a Forget
|
|
333
|
+
button. A learned rate limit not met again for 14 days is forgotten, so a
|
|
334
|
+
raised limit is found. One exception,
|
|
327
335
|
watched: NVIDIA states no limit per key, so its tile reads the file
|
|
328
336
|
build.nvidia.com's own badge reads (10,000 requests a day, up to 40 a minute,
|
|
329
337
|
the same for every account) daily and on Refresh, and counts the bench's
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.1
|
package/metrics/decide.py
CHANGED
|
@@ -34,6 +34,14 @@ URL, which nothing writes (an Open WebUI hub is given one by hand), and the
|
|
|
34
34
|
onboarding hints about it show only for an Open WebUI hub (endpoints.hub). OpenRouter's /models omits its decision
|
|
35
35
|
models, so that endpoint names them in `unlisted_models` to make them pickable.
|
|
36
36
|
|
|
37
|
+
Cloudflare Workers AI answers decisions only on `/ai/run/<model>`, outside its
|
|
38
|
+
OpenAI-compatible `/ai/v1` base, so no hub can relay them: its endpoint's
|
|
39
|
+
`decisions` URL is `.../accounts/<id>/ai/run/{model}`. `{model}` puts the
|
|
40
|
+
served id in the URL and leaves it out of the body (Cloudflare refuses
|
|
41
|
+
`@cf/...` there), and its `{"result": {...}}` wrapper is taken off the reply.
|
|
42
|
+
Its /models answers 405, so that endpoint lists from `models_url`, the
|
|
43
|
+
catalog search (`.../ai/models/search`), read by endpoints.list_models.
|
|
44
|
+
|
|
37
45
|
WHAT EACH MODEL CAN ANSWER
|
|
38
46
|
--------------------------
|
|
39
47
|
`question_types` lists the types a model accepted when onboarding probed it
|
|
@@ -235,7 +243,13 @@ def call(endpoint: str, served: str, state, questions: dict, timeout: int = 60,
|
|
|
235
243
|
headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
|
|
236
244
|
if (secret := endpoints.key(endpoint)):
|
|
237
245
|
headers["Authorization"] = f"Bearer {secret}"
|
|
238
|
-
|
|
246
|
+
payload = {"model": served, "state": state, "questions": questions}
|
|
247
|
+
if "{model}" in url:
|
|
248
|
+
# Cloudflare names the model in the URL (/ai/run/@cf/cloudflare/clef)
|
|
249
|
+
# and refuses its own id in the body ("clef", not "@cf/...", is valid there).
|
|
250
|
+
url = url.replace("{model}", served)
|
|
251
|
+
del payload["model"]
|
|
252
|
+
body = json.dumps(payload).encode()
|
|
239
253
|
# Inception answers 429 to calls in quick succession (seen 2026-10-16:
|
|
240
254
|
# the second of three onboarding probes), so a 429 waits and retries.
|
|
241
255
|
for wait in ((5, 15, 0) if retry else (0,)):
|
|
@@ -243,6 +257,9 @@ def call(endpoint: str, served: str, state, questions: dict, timeout: int = 60,
|
|
|
243
257
|
try:
|
|
244
258
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
245
259
|
reply = json.load(response)
|
|
260
|
+
# Cloudflare wraps every reply: {"result": {"answers": ...}, "success": true}.
|
|
261
|
+
if isinstance(reply, dict) and "answers" not in reply and isinstance(reply.get("result"), dict):
|
|
262
|
+
reply = reply["result"]
|
|
246
263
|
return reply
|
|
247
264
|
except urllib.error.HTTPError as exc:
|
|
248
265
|
if exc.code != 429 or not wait:
|
|
@@ -358,14 +375,23 @@ def refuses_chat(endpoint: str, served: str) -> bool:
|
|
|
358
375
|
with urllib.request.urlopen(request, timeout=60):
|
|
359
376
|
return False
|
|
360
377
|
except urllib.error.HTTPError as exc:
|
|
361
|
-
|
|
362
|
-
# own way, so any refusal that names decisions or systemone counts.
|
|
363
|
-
text = error_text(exc).lower()
|
|
364
|
-
return any(w in text for w in ("decisions model", "decision model", "systemone", "/decisions"))
|
|
378
|
+
return names_decisions(error_text(exc))
|
|
365
379
|
except Exception: # noqa: BLE001
|
|
366
380
|
return False
|
|
367
381
|
|
|
368
382
|
|
|
383
|
+
def names_decisions(text: str) -> bool:
|
|
384
|
+
"""A chat refusal that says the model is a decision model. OpenRouter names
|
|
385
|
+
it ("is a decisions model"); Cloudflare only lists the fields it wanted
|
|
386
|
+
("required properties ... 'model,state,questions'", "Required value
|
|
387
|
+
missing: questions"). Every Decisions API takes `state` and `questions`,
|
|
388
|
+
so a refusal asking for both counts, whoever words it."""
|
|
389
|
+
low = (text or "").lower()
|
|
390
|
+
if any(w in low for w in ("decisions model", "decision model", "systemone", "/decisions")):
|
|
391
|
+
return True
|
|
392
|
+
return "questions" in low and ("state" in low or "required" in low)
|
|
393
|
+
|
|
394
|
+
|
|
369
395
|
def question_types(endpoint: str, served: str, path: str = "decisions") -> list[str]:
|
|
370
396
|
"""Which question types the model accepts, one probe call per type.
|
|
371
397
|
|
package/metrics/endpoints.py
CHANGED
|
@@ -295,7 +295,8 @@ def models(name: str | None = None, timeout: int = 30) -> dict[str, dict]:
|
|
|
295
295
|
def list_models(entry: dict, secret: str = "", timeout: int = 30) -> dict[str, dict]:
|
|
296
296
|
"""What an endpoint entry lists, saved or not yet (the dashboard tests one
|
|
297
297
|
before saving it). `models_url` when the list lives elsewhere (GitHub
|
|
298
|
-
Models: its catalog, a bare JSON list
|
|
298
|
+
Models: its catalog, a bare JSON list; Cloudflare: its catalog search,
|
|
299
|
+
{"result": [...]} by `name`); entries marked `visibility` other
|
|
299
300
|
than "list" are left out (ChatGPT's plan lists ones it will not serve)."""
|
|
300
301
|
api = entry.get("api") or "chat"
|
|
301
302
|
headers = dict(entry.get("headers") or {})
|
|
@@ -313,6 +314,9 @@ def list_models(entry: dict, secret: str = "", timeout: int = 30) -> dict[str, d
|
|
|
313
314
|
# what generateContent serves, by its bare name.
|
|
314
315
|
rows = [{**m, "id": str(m.get("name", "")).removeprefix("models/")} for m in data.get("models", [])
|
|
315
316
|
if isinstance(m, dict) and "generateContent" in (m.get("supportedGenerationMethods") or ())]
|
|
317
|
+
elif isinstance(data, dict) and isinstance(data.get("result"), list):
|
|
318
|
+
# Cloudflare's catalog search: {"result": [{"name": "@cf/...", ...}]}.
|
|
319
|
+
rows = [{**m, "id": m.get("name")} for m in data["result"] if isinstance(m, dict)]
|
|
316
320
|
else:
|
|
317
321
|
rows = data.get("data", []) if isinstance(data, dict) else data
|
|
318
322
|
return {e["id"]: e for e in rows
|
package/metrics/learn.py
CHANGED
|
@@ -13,18 +13,40 @@ learns exactly these at runtime, and so does this: every failed call from the
|
|
|
13
13
|
perf probe, the suite (and so suite/pace.py) and the gateway passes its error
|
|
14
14
|
text through `record()`.
|
|
15
15
|
|
|
16
|
-
ONLY EVER TIGHTER
|
|
17
|
-
|
|
16
|
+
ONLY EVER TIGHTER, UNTIL IT EXPIRES
|
|
17
|
+
-----------------------------------
|
|
18
18
|
A learned context window or rate limit is kept only if it is LOWER than what is
|
|
19
19
|
known, and replaces an older learned value only when lower still. An error can
|
|
20
20
|
prove a ceiling exists; it cannot prove the ceiling is higher than a catalog
|
|
21
21
|
says. Values you entered in the model popup (config/model-overrides.json)
|
|
22
22
|
still win: you can always correct a bad parse.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
A rate limit can be raised, though (a plan upgrade, a provider's change), and
|
|
25
|
+
no error ever says so. So a learned RATE limit that has not been seen again for
|
|
26
|
+
LEARNED_TTL_S (14 days) is left out wherever it is read: the bench goes back to
|
|
27
|
+
the higher rate, and the provider's next refusal teaches the limit again. The
|
|
28
|
+
same limit seen again refreshes its `confirmed_at`, so one that still holds
|
|
29
|
+
does not expire. A context window does not: a model's window does not grow.
|
|
30
|
+
|
|
31
|
+
A 429 THAT NAMES NO NUMBER
|
|
32
|
+
--------------------------
|
|
33
|
+
Cloudflare Workers AI says only that the day's free Neurons are used up. The
|
|
34
|
+
bench knows how many requests it sent on that connection in the window before
|
|
35
|
+
the refusal (metrics/ledger.py), and that count is the limit, near enough. It
|
|
36
|
+
is a connection's limit, not a model's: the key's allowance is shared. One
|
|
37
|
+
refusal is a candidate; a second at a count within 20% of it, a window or more
|
|
38
|
+
later (sooner is the same refusal, met by another call), is adopted (the lower
|
|
39
|
+
of the two) and enforced by metrics/pacing.py unless you set your own for that
|
|
40
|
+
connection. Seen again near the same count it is confirmed; a refusal at a
|
|
41
|
+
clearly lower count lowers it at once. Only rpm and rpd, and never from the
|
|
42
|
+
bench's own refusal.
|
|
43
|
+
|
|
44
|
+
config/model-learned.json alias -> {context_window, limits, capabilities, served_as}
|
|
45
|
+
with the date and the evidence for each
|
|
46
|
+
config/connection-learned.json connection -> {rpm, rpd}: {value, at, confirmed_at, where,
|
|
47
|
+
evidence}, or the candidates until one is adopted
|
|
48
|
+
runs/cooldowns.json alias -> {until, why}: rate-limited, come back later.
|
|
49
|
+
Not config: it expires, and is nobody's decision.
|
|
28
50
|
"""
|
|
29
51
|
|
|
30
52
|
from __future__ import annotations
|
|
@@ -40,9 +62,13 @@ import locked
|
|
|
40
62
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
41
63
|
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
42
64
|
LEARNED = DATA / "config" / "model-learned.json"
|
|
65
|
+
CONN_LEARNED = DATA / "config" / "connection-learned.json"
|
|
43
66
|
COOLDOWNS = DATA / "runs" / "cooldowns.json"
|
|
44
67
|
DEFAULT_COOLDOWN_S = 1800 # a 429 with no stated wait
|
|
45
68
|
MAX_COOLDOWN_S = 24 * 3600
|
|
69
|
+
LEARNED_TTL_S = 14 * 86400 # a learned rate limit not seen again for this long is forgotten
|
|
70
|
+
CONN_AXES = {"rpm": 60, "rpd": 86400} # what a connection's limit is learned for: its window, seconds
|
|
71
|
+
MIN_COUNT = 3 # fewer requests in the window than this prove nothing
|
|
46
72
|
|
|
47
73
|
CONTEXT = [
|
|
48
74
|
r"maximum context length is\s*([\d,]+)",
|
|
@@ -71,6 +97,10 @@ ALLOWS = re.compile(r"allows?\s+([\d,]+)\s+requests?(?:\(s\))?\s+every\s+(?:1\s+
|
|
|
71
97
|
# new-api gateways (Agnes): "maximum 20 requests in 1 minutes" (its
|
|
72
98
|
# middleware/model-rate-limit.go); a window of 1 minute or 1,440 only.
|
|
73
99
|
MAXIMUM = re.compile(r"maximum\s+([\d,]+)\s+requests?\s+in\s+(1|1440)\s+minutes?\b")
|
|
100
|
+
# Which window a 429 with no number is about. A window, not a wait: "try
|
|
101
|
+
# again in 3 minutes" names no per-minute limit.
|
|
102
|
+
DAY = re.compile(r"\bday\b|daily|free allocation|neurons")
|
|
103
|
+
MINUTE = re.compile(r"per[- ]?min(?:ute)?\b|a minute|\brpm\b")
|
|
74
104
|
|
|
75
105
|
|
|
76
106
|
def _int(s: str) -> int:
|
|
@@ -108,18 +138,64 @@ def parse(text: str) -> dict:
|
|
|
108
138
|
_read = locked.read_json
|
|
109
139
|
|
|
110
140
|
|
|
141
|
+
def _age(stamp, now: dt.datetime) -> float:
|
|
142
|
+
"""Seconds since an ISO stamp; forever when it is missing or unreadable."""
|
|
143
|
+
try:
|
|
144
|
+
return (now - dt.datetime.fromisoformat(stamp)).total_seconds()
|
|
145
|
+
except (TypeError, ValueError):
|
|
146
|
+
return float("inf")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _live(ev: dict, now: dt.datetime) -> bool:
|
|
150
|
+
"""Seen within LEARNED_TTL_S: an older rate limit may have been raised since."""
|
|
151
|
+
return _age(ev.get("confirmed_at") or ev.get("at"), now) <= LEARNED_TTL_S
|
|
152
|
+
|
|
153
|
+
|
|
111
154
|
def learned() -> dict:
|
|
112
|
-
|
|
155
|
+
"""alias -> what was learned, without the rate limits that have expired."""
|
|
156
|
+
now = dt.datetime.now().astimezone()
|
|
157
|
+
out = {}
|
|
158
|
+
for alias, e in _read(LEARNED).items():
|
|
159
|
+
if alias.startswith("_"):
|
|
160
|
+
continue
|
|
161
|
+
if isinstance(e, dict) and e.get("limits"):
|
|
162
|
+
e = {**e, "limits": {k: ev for k, ev in e["limits"].items() if _live(ev, now)}}
|
|
163
|
+
out[alias] = e
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def connection_learned(pending: bool = False) -> dict:
|
|
168
|
+
"""connection -> {axis: {value, at, confirmed_at, where, evidence}}: the
|
|
169
|
+
adopted limits that have not expired. pending=True: for the axes with none,
|
|
170
|
+
the latest candidate instead ({n, at}), seen once and not enforced."""
|
|
171
|
+
now = dt.datetime.now().astimezone()
|
|
172
|
+
out: dict = {}
|
|
173
|
+
for conn, axes in _read(CONN_LEARNED).items():
|
|
174
|
+
if conn.startswith("_") or not isinstance(axes, dict):
|
|
175
|
+
continue
|
|
176
|
+
for axis, e in axes.items():
|
|
177
|
+
if axis not in CONN_AXES or not isinstance(e, dict):
|
|
178
|
+
continue
|
|
179
|
+
adopted = bool(e.get("value")) and _live(e, now)
|
|
180
|
+
waiting = [c for c in e.get("candidates") or [] if _live(c, now)]
|
|
181
|
+
if adopted and not pending:
|
|
182
|
+
out.setdefault(conn, {})[axis] = {k: v for k, v in e.items() if k != "candidates"}
|
|
183
|
+
elif pending and not adopted and waiting:
|
|
184
|
+
out.setdefault(conn, {})[axis] = waiting[-1]
|
|
185
|
+
return out
|
|
113
186
|
|
|
114
187
|
|
|
115
188
|
def _known(alias: str, field: str, axis: str | None = None):
|
|
116
|
-
"""The value a learned figure has to beat: the catalog's, else the last
|
|
189
|
+
"""The value a learned figure has to beat: the catalog's, else the last
|
|
190
|
+
learned; for a rate limit, its connection's too, yours and the learned."""
|
|
117
191
|
import catalog
|
|
118
192
|
auto = catalog.catalog().get(alias, {})
|
|
119
193
|
mine = learned().get(alias, {})
|
|
120
194
|
if axis:
|
|
195
|
+
conn = catalog.connection_of(alias)
|
|
121
196
|
vals = [(mine.get("limits") or {}).get(axis, {}).get("value"),
|
|
122
|
-
catalog.connection_limits().get(
|
|
197
|
+
catalog.connection_limits().get(conn, {}).get(axis),
|
|
198
|
+
connection_learned().get(conn, {}).get(axis, {}).get("value")]
|
|
123
199
|
else:
|
|
124
200
|
vals = [(mine.get(field) or {}).get("value"), auto.get(field)]
|
|
125
201
|
vals = [v for v in vals if v]
|
|
@@ -137,10 +213,13 @@ def _tighten(alias: str, found: dict, where: str, evidence: str, stamp: str) ->
|
|
|
137
213
|
entry["context_window"] = {"value": n, "at": stamp, "where": where, "evidence": evidence}
|
|
138
214
|
stored["context_window"] = n
|
|
139
215
|
for axis, n in (found.get("limits") or {}).items():
|
|
140
|
-
|
|
216
|
+
had = (entry.get("limits") or {}).get(axis) or {}
|
|
217
|
+
if n < (_known(alias, "", axis) or 1 << 62): # an expired one is not known: a higher value may replace it
|
|
141
218
|
entry.setdefault("limits", {})[axis] = {"value": n, "at": stamp, "where": where,
|
|
142
219
|
"evidence": evidence}
|
|
143
220
|
stored[axis] = n
|
|
221
|
+
elif had.get("value") == n:
|
|
222
|
+
had["confirmed_at"] = stamp # still true, so it does not expire
|
|
144
223
|
if stored:
|
|
145
224
|
data[alias] = entry
|
|
146
225
|
data.setdefault("_why", "Learned from error messages by metrics/learn.py; only ever"
|
|
@@ -158,6 +237,75 @@ def note_limits(alias: str, limits: dict, where: str, evidence: str) -> dict:
|
|
|
158
237
|
return _tighten(alias, {"limits": {k: v for k, v in limits.items() if k in AXES}}, where, evidence, stamp)
|
|
159
238
|
|
|
160
239
|
|
|
240
|
+
def _axis(text: str, found: dict) -> str | None:
|
|
241
|
+
"""The window a 429 that names no number is about, or None: nothing to
|
|
242
|
+
learn. Never the bench's own refusal ("not sent", pacing.py): a limit the
|
|
243
|
+
bench enforced must not be learned back from itself. Nor a token limit: the
|
|
244
|
+
count is of requests."""
|
|
245
|
+
low = (text or "").lower()
|
|
246
|
+
if not found.get("rate_limited") or found.get("limits") or "not sent" in low \
|
|
247
|
+
or re.search(AXES["tpm"] + "|" + AXES["tpd"], low):
|
|
248
|
+
return None
|
|
249
|
+
wait = found.get("retry_after_s")
|
|
250
|
+
if DAY.search(low) or (wait or 0) >= 3600:
|
|
251
|
+
return "rpd"
|
|
252
|
+
# A short wait alone is not enough for rpm: calls in flight are not in the
|
|
253
|
+
# ledger yet, so a minute's count undercounts, and two passing 429s could
|
|
254
|
+
# pin a connection at rpm 3 for 14 days. Only a named minute window counts.
|
|
255
|
+
if MINUTE.search(low):
|
|
256
|
+
return "rpm"
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _sent(connection: str, since: float) -> int:
|
|
261
|
+
"""Requests the provider took on a connection since then: the ledger's
|
|
262
|
+
attempts, without the calls that failed, whose own retries would count the
|
|
263
|
+
refusal as use."""
|
|
264
|
+
import ledger
|
|
265
|
+
return sum(r.get("attempts") or 0 for r in ledger.rows(since)
|
|
266
|
+
if r.get("connection") == connection and r.get("status") != "error")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _near(n: int, value: int) -> bool:
|
|
270
|
+
return 0.8 * value <= n <= 1.2 * value
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _learn_connection(connection: str, axis: str, n: int, where: str, evidence: str, now: dt.datetime) -> dict:
|
|
274
|
+
"""A connection refused after `n` requests in the axis's window. Returns
|
|
275
|
+
{axis: value} when that adopted a limit or lowered one."""
|
|
276
|
+
stamp, span = now.isoformat(timespec="seconds"), CONN_AXES[axis]
|
|
277
|
+
stored: dict = {}
|
|
278
|
+
|
|
279
|
+
def change(data: dict) -> None:
|
|
280
|
+
axes = data.setdefault(connection, {})
|
|
281
|
+
e = axes.get(axis) or {}
|
|
282
|
+
fresh = {"value": n, "at": stamp, "confirmed_at": stamp, "where": where, "evidence": evidence}
|
|
283
|
+
waiting = [c for c in e.get("candidates") or [] if _live(c, now)]
|
|
284
|
+
if e.get("value") and _live(e, now):
|
|
285
|
+
# One lesson a window: while a provider keeps refusing, the count
|
|
286
|
+
# of what it took only falls, and would drag the limit down with it.
|
|
287
|
+
if _age(e.get("confirmed_at") or e.get("at"), now) < span:
|
|
288
|
+
return
|
|
289
|
+
if n < 0.8 * e["value"]: # refused well below it: that is proof
|
|
290
|
+
axes[axis] = fresh
|
|
291
|
+
stored[axis] = n
|
|
292
|
+
elif _near(n, e["value"]):
|
|
293
|
+
e["confirmed_at"] = stamp
|
|
294
|
+
elif waiting and _age(waiting[-1]["at"], now) < span:
|
|
295
|
+
return # the same refusal, met by another call
|
|
296
|
+
elif agree := [c["n"] for c in waiting if _near(n, c["n"])]:
|
|
297
|
+
axes[axis] = fresh | {"value": min(agree + [n])}
|
|
298
|
+
stored[axis] = axes[axis]["value"]
|
|
299
|
+
else:
|
|
300
|
+
axes[axis] = {"candidates": (waiting + [{"n": n, "at": stamp}])[-5:]}
|
|
301
|
+
data.setdefault("_why", "Learned by metrics/learn.py from 429s that named no number: the requests"
|
|
302
|
+
" the bench had sent in the window. Enforced unless you set your own"
|
|
303
|
+
" (config/connection-limits.json); forgotten 14 days after last seen.")
|
|
304
|
+
|
|
305
|
+
locked.update(CONN_LEARNED, change, ensure_ascii=False)
|
|
306
|
+
return stored
|
|
307
|
+
|
|
308
|
+
|
|
161
309
|
def record(alias: str, text: str, where: str) -> dict:
|
|
162
310
|
"""Learn from one failed call. Returns what was stored or set, for logging."""
|
|
163
311
|
found = parse(text)
|
|
@@ -165,6 +313,14 @@ def record(alias: str, text: str, where: str) -> dict:
|
|
|
165
313
|
stamp = now.isoformat(timespec="seconds")
|
|
166
314
|
evidence = " ".join((text or "").split())[:200]
|
|
167
315
|
stored = _tighten(alias, found, where, evidence, stamp)
|
|
316
|
+
if alias and (axis := _axis(text, found)):
|
|
317
|
+
try:
|
|
318
|
+
import catalog
|
|
319
|
+
conn = catalog.connection_of(alias)
|
|
320
|
+
if conn and (n := _sent(conn, now.timestamp() - CONN_AXES[axis])) >= MIN_COUNT:
|
|
321
|
+
stored.update(_learn_connection(conn, axis, n, where, evidence, now))
|
|
322
|
+
except Exception: # noqa: BLE001 -- learning never breaks a call
|
|
323
|
+
pass
|
|
168
324
|
if alias and found.get("rate_limited"):
|
|
169
325
|
wait = found.get("retry_after_s") or DEFAULT_COOLDOWN_S
|
|
170
326
|
cool = {"until": (now + dt.timedelta(seconds=wait)).isoformat(timespec="seconds"),
|
|
@@ -231,9 +387,104 @@ def _selftest() -> None:
|
|
|
231
387
|
assert "limits" not in parse("maximum 100 requests in 5 minutes") # a window no axis holds
|
|
232
388
|
assert parse("model not found") == {"rate_limited": False}
|
|
233
389
|
assert parse("context length of 8k exceeded").get("context_window") is None # "8" < 512
|
|
390
|
+
|
|
391
|
+
# Which window a 429 with no number is about.
|
|
392
|
+
def axis(text: str):
|
|
393
|
+
return _axis(text, parse(text))
|
|
394
|
+
neurons = "Error code: 429 - you have used up your daily free allocation of 10,000 neurons"
|
|
395
|
+
assert axis(neurons) == "rpd" and "limits" not in parse(neurons)
|
|
396
|
+
assert axis("Rate limit exceeded: free-models-per-day. Try again in 3 minutes") == "rpd"
|
|
397
|
+
assert axis("429 Too Many Requests, retry-after: 7200") == "rpd"
|
|
398
|
+
assert axis("429 Too Many Requests, retry-after: 30") is None # a short wait alone: no window
|
|
399
|
+
assert axis("429: too many requests per minute") == "rpm"
|
|
400
|
+
assert axis("429 Too Many Requests") is None # no window named
|
|
401
|
+
assert axis("Rate limit exceeded. Try again in 3 minutes") is None # a wait, not a window
|
|
402
|
+
assert axis("429: rate limit on tokens per minute") is None # requests are what is counted
|
|
403
|
+
assert axis("Limit 30, Used 30 (requests per minute) 429") is None # it gave the number
|
|
404
|
+
assert axis("model not found") is None
|
|
405
|
+
# The bench's own refusal is never learned from.
|
|
406
|
+
refusal = ("Error: client failed: rate limit (not sent; paced by the bench): x allows 50 requests"
|
|
407
|
+
" per day (learned) and 50 were used; try again in 2h")
|
|
408
|
+
assert axis(refusal) is None and axis(refusal.replace("not sent; ", "")) == "rpd"
|
|
409
|
+
_selftest_stores(neurons, refusal)
|
|
234
410
|
print("ok")
|
|
235
411
|
|
|
236
412
|
|
|
413
|
+
def _selftest_stores(neurons: str, refusal: str) -> None:
|
|
414
|
+
"""The connection store and the expiry, in a temp folder with a stub
|
|
415
|
+
catalog: nothing of the install's is read or written."""
|
|
416
|
+
import tempfile
|
|
417
|
+
|
|
418
|
+
import catalog
|
|
419
|
+
import ledger
|
|
420
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
421
|
+
tmp = pathlib.Path(folder)
|
|
422
|
+
globals().update(LEARNED=tmp / "model-learned.json", CONN_LEARNED=tmp / "connection-learned.json",
|
|
423
|
+
COOLDOWNS=tmp / "cooldowns.json")
|
|
424
|
+
ledger.DIR = tmp / "usage"
|
|
425
|
+
catalog.catalog = catalog.connection_limits = lambda: {}
|
|
426
|
+
catalog.connection_of = lambda alias: "conn-" + alias
|
|
427
|
+
now = dt.datetime.now().astimezone()
|
|
428
|
+
|
|
429
|
+
def ago(**kw) -> dt.datetime:
|
|
430
|
+
return now - dt.timedelta(**kw)
|
|
431
|
+
|
|
432
|
+
def refused(n: int, when: dt.datetime, conn: str = "prov", ax: str = "rpd") -> dict:
|
|
433
|
+
return _learn_connection(conn, ax, n, "test", "429", when)
|
|
434
|
+
# One refusal is a candidate; the same refusal met again changes nothing.
|
|
435
|
+
assert refused(100, ago(days=5)) == {} and connection_learned() == {}
|
|
436
|
+
assert connection_learned(pending=True)["prov"]["rpd"]["n"] == 100
|
|
437
|
+
assert refused(104, ago(days=5, hours=-1)) == {} and connection_learned() == {}
|
|
438
|
+
# A second count far from the first does not adopt; one within 20% does, the lower of the two.
|
|
439
|
+
assert refused(300, ago(days=4)) == {} and connection_learned() == {}
|
|
440
|
+
assert refused(110, ago(days=3)) == {"rpd": 100}
|
|
441
|
+
got = connection_learned()["prov"]["rpd"]
|
|
442
|
+
assert got["value"] == 100 and "candidates" not in got and connection_learned(pending=True) == {}, got
|
|
443
|
+
# Seen again near it: confirmed. Clearly lower: lowered at once. Higher: left alone.
|
|
444
|
+
assert refused(95, ago(days=2)) == {}
|
|
445
|
+
got = connection_learned()["prov"]["rpd"]
|
|
446
|
+
assert got["value"] == 100 and got["confirmed_at"] == ago(days=2).isoformat(timespec="seconds") != got["at"]
|
|
447
|
+
assert refused(40, ago(days=2, hours=-1)) == {} # within the window: the same refusal
|
|
448
|
+
assert refused(500, ago(days=1)) == {} and connection_learned()["prov"]["rpd"]["value"] == 100
|
|
449
|
+
assert refused(60, now) == {"rpd": 60} and connection_learned()["prov"]["rpd"]["value"] == 60
|
|
450
|
+
# Not seen for 15 days: forgotten on read, and a higher limit can be learned.
|
|
451
|
+
assert refused(20, ago(days=16), "old", "rpm") == {} and refused(20, ago(days=15), "old", "rpm") == {"rpm": 20}
|
|
452
|
+
assert "old" not in connection_learned()
|
|
453
|
+
assert refused(50, ago(minutes=5), "old", "rpm") == {} and refused(50, now, "old", "rpm") == {"rpm": 50}
|
|
454
|
+
|
|
455
|
+
# A model's own learned limit: the same expiry, and the same limit seen again is confirmed.
|
|
456
|
+
old = {"value": 5, "at": ago(days=15).isoformat(timespec="seconds"), "where": "test", "evidence": ""}
|
|
457
|
+
LEARNED.write_text(json.dumps({"m": {"limits": {"rpm": dict(old), "rpd": dict(old)},
|
|
458
|
+
"context_window": dict(old, value=8192)}}), encoding="utf-8")
|
|
459
|
+
assert learned()["m"]["limits"] == {} and learned()["m"]["context_window"]["value"] == 8192
|
|
460
|
+
stamp = now.isoformat(timespec="seconds")
|
|
461
|
+
assert _tighten("m", {"limits": {"rpm": 20}}, "test", "", stamp) == {"rpm": 20}
|
|
462
|
+
assert _tighten("m", {"limits": {"rpm": 30}}, "test", "", stamp) == {} # live: only ever tighter
|
|
463
|
+
assert "confirmed_at" not in learned()["m"]["limits"]["rpm"]
|
|
464
|
+
again = ago(hours=-1).isoformat(timespec="seconds")
|
|
465
|
+
assert _tighten("m", {"limits": {"rpm": 20}}, "test", "", again) == {}
|
|
466
|
+
got = learned()["m"]["limits"]["rpm"]
|
|
467
|
+
assert got["value"] == 20 and got["at"] == stamp and got["confirmed_at"] == again, got
|
|
468
|
+
# A model's limit must beat its connection's learned one too.
|
|
469
|
+
assert refused(10, ago(minutes=5), "conn-m2", "rpm") == {} and refused(10, now, "conn-m2", "rpm") == {"rpm": 10}
|
|
470
|
+
assert _tighten("m2", {"limits": {"rpm": 60}}, "test", "", stamp) == {}
|
|
471
|
+
assert _tighten("m2", {"limits": {"rpm": 6}}, "test", "", stamp) == {"rpm": 6}
|
|
472
|
+
|
|
473
|
+
# record(): the count is the ledger's, of what the provider took on that connection.
|
|
474
|
+
def sent(conn: str, n: int, status: str = "ok", attempts: int = 1) -> None:
|
|
475
|
+
for i in range(n):
|
|
476
|
+
ledger.append({"t": now.timestamp() - 30 - i, "alias": "x", "connection": conn, "status": status,
|
|
477
|
+
"attempts": attempts})
|
|
478
|
+
sent("conn-r", 4), sent("conn-r", 1, "error", 3), sent("elsewhere", 9), sent("conn-p", 4), sent("conn-few", 2)
|
|
479
|
+
assert record("r", neurons, "test") == {"cooldown_s": DEFAULT_COOLDOWN_S} # a candidate: nothing stored yet
|
|
480
|
+
assert connection_learned(pending=True)["conn-r"]["rpd"]["n"] == 4
|
|
481
|
+
assert record("few", neurons, "test") and "conn-few" not in connection_learned(pending=True) # too few to say
|
|
482
|
+
# The bench's own refusal cools the model as before, and teaches nothing.
|
|
483
|
+
assert record("p", refusal, "test") == {"cooldown_s": 7200} and "p" in cooldowns()
|
|
484
|
+
assert "conn-p" not in connection_learned(pending=True)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
|
|
237
488
|
def main() -> None:
|
|
238
489
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
239
490
|
if "--selftest" in sys.argv:
|
|
@@ -248,6 +499,9 @@ def main() -> None:
|
|
|
248
499
|
if e.get("served_as"):
|
|
249
500
|
bits.append(f"served as {e['served_as']['served']}")
|
|
250
501
|
print(f" {alias:<34} {', '.join(bits)}")
|
|
502
|
+
for conn, axes in sorted(connection_learned().items()):
|
|
503
|
+
bits = [f"{k} {v['value']}" for k, v in axes.items()]
|
|
504
|
+
print(f" {conn:<34} {', '.join(bits)} (the connection's, from 429s with no number)")
|
|
251
505
|
for alias, c in sorted(cooldowns().items()):
|
|
252
506
|
print(f" cooling {alias:<26} until {c['until'][11:16]} ({c['why'][:70]})")
|
|
253
507
|
|
package/metrics/pacing.py
CHANGED
|
@@ -28,7 +28,9 @@ WHAT IS COUNTED
|
|
|
28
28
|
deadline is dropped, so a killed process cannot hold a slot.
|
|
29
29
|
- A connection's limits count every model on it (a key's limits are the
|
|
30
30
|
key's); a model's own or learned limits count that model alone, its own
|
|
31
|
-
beating the learned (as everywhere: your values win).
|
|
31
|
+
beating the learned (as everywhere: your values win). Where you set none
|
|
32
|
+
for a connection, the rpm or rpd learn.py worked out from its 429s is kept
|
|
33
|
+
to (config/connection-learned.json), until it expires.
|
|
32
34
|
- Quota records (metrics/quota.py): what a provider last said was left, less
|
|
33
35
|
the calls sent since, until its reset.
|
|
34
36
|
|
|
@@ -77,6 +79,7 @@ def rules(alias: str, connection: str) -> list[dict]:
|
|
|
77
79
|
conn = catalog.connection_limits().get(connection) or {}
|
|
78
80
|
mine = (catalog.overrides().get(alias) or {}).get("limits") or {}
|
|
79
81
|
seen = (learn.learned().get(alias) or {}).get("limits") or {}
|
|
82
|
+
met = learn.connection_learned().get(connection) or {}
|
|
80
83
|
out = []
|
|
81
84
|
|
|
82
85
|
def ok(n) -> bool:
|
|
@@ -85,6 +88,10 @@ def rules(alias: str, connection: str) -> list[dict]:
|
|
|
85
88
|
if ok(n := conn.get(axis)):
|
|
86
89
|
out.append({"scope": "connection", "axis": axis, "limit": n,
|
|
87
90
|
"source": f"you, for {connection} (config/connection-limits.json)"})
|
|
91
|
+
elif ok(n := (met.get(axis) or {}).get("value")):
|
|
92
|
+
out.append({"scope": "connection", "axis": axis, "limit": n,
|
|
93
|
+
"source": f"learned {str(met[axis].get('at', ''))[:10]} from 429s on {connection}"
|
|
94
|
+
" (config/connection-learned.json)"})
|
|
88
95
|
if axis == "rpmo":
|
|
89
96
|
continue
|
|
90
97
|
if ok(n := mine.get(axis)):
|
package/metrics/quotaview.py
CHANGED
|
@@ -512,14 +512,21 @@ def _limit_text(lim: dict) -> str:
|
|
|
512
512
|
return ", ".join(f"{v:,} {words[k]}" for k, v in lim.items() if k in words and v)
|
|
513
513
|
|
|
514
514
|
|
|
515
|
-
def caps(host: str, entered: dict, stated: dict, day_rows: list, month_rows, now: float
|
|
515
|
+
def caps(host: str, entered: dict, stated: dict, day_rows: list, month_rows, now: float,
|
|
516
|
+
learned: dict | None = None) -> list[dict]:
|
|
516
517
|
"""The connection's day and month caps as groups of windows, each counted
|
|
517
518
|
from the bench's ledger since midnight UTC or the month's start (what a
|
|
518
|
-
provider's calendar counts): yours, else `
|
|
519
|
-
|
|
519
|
+
provider's calendar counts): yours, else `learned` (learn.py's, from this
|
|
520
|
+
connection's 429s), else `stated` ({axis: (limit, source, detail)}: what
|
|
521
|
+
its replies say with nothing left given, or NVIDIA's site).
|
|
520
522
|
`month_rows` is called only when a month cap needs it."""
|
|
521
523
|
mine = {k: (v, "your cap", "Set on Endpoints, Rate limits per connection; the bench keeps to it.")
|
|
522
524
|
for k, v in (entered.get(host) or {}).items() if k in COUNTED and v}
|
|
525
|
+
for axis, ev in (learned or {}).items():
|
|
526
|
+
if axis in COUNTED:
|
|
527
|
+
mine.setdefault(axis, (ev["value"], "learned", f"Worked out {str(ev.get('at'))[:10]} from this connection's"
|
|
528
|
+
" 429s, which named no number; the bench keeps to it until you set your own"
|
|
529
|
+
" on Endpoints, or it has not been met again for 14 days."))
|
|
523
530
|
for axis, (n, source, detail) in stated.items():
|
|
524
531
|
mine.setdefault(axis, (n, source, detail + " Shown, not enforced: set it on Endpoints to have the bench"
|
|
525
532
|
" keep to it."))
|
|
@@ -554,7 +561,7 @@ def tiles(now: float | None = None) -> list[dict]:
|
|
|
554
561
|
resting, learned = learn.cooldowns(), learn.learned()
|
|
555
562
|
day_rows = list(ledger.rows(now - 86400))
|
|
556
563
|
utc_rows = [r for r in day_rows if r["t"] >= _utc_midnight(now)]
|
|
557
|
-
entered = catalog.connection_limits()
|
|
564
|
+
entered, met = catalog.connection_limits(), learn.connection_learned()
|
|
558
565
|
regs = __import__("endpoints").registrations()
|
|
559
566
|
month: list = [] # this month's ledger, read once if a month cap asks
|
|
560
567
|
|
|
@@ -589,7 +596,7 @@ def tiles(now: float | None = None) -> list[dict]:
|
|
|
589
596
|
t = {"id": tag, "tag": tag, "host": host, "bench": len(aliases), "kind": "models", "groups": [],
|
|
590
597
|
"pool": [], "notes": [], # a day or month cap is a meter (caps), a minute one is said
|
|
591
598
|
"conn_limit": _limit_text({k: v for k, v in (entered.get(host) or {}).items() if k in ("rpm", "tpm")}),
|
|
592
|
-
"caps": caps(host, entered, stated, day_rows, month_rows, now),
|
|
599
|
+
"caps": caps(host, entered, stated, day_rows, month_rows, now, met.get(host)),
|
|
593
600
|
"models": _model_rows(aliases, rows, resting, limits),
|
|
594
601
|
"refresh": how_refreshed(tag), "plan": "", "name": ""}
|
|
595
602
|
got = live.get(tag) or {}
|
|
@@ -743,6 +750,13 @@ def _selftest() -> None:
|
|
|
743
750
|
g = caps("router.requesty.ai", {"router.requesty.ai": {"rpd": 10, "rpm": 5}}, {"rpd": said}, day, lambda: [], now)
|
|
744
751
|
assert g[0]["label"] == "your cap · counted by the bench" and g[0]["windows"] == [
|
|
745
752
|
{"label": "requests · day", "left": 80, "remaining": 8, "of": 10, "reset_at": _iso(mid + 86400)}], g
|
|
753
|
+
# A learned day cap (learn.py, from 429s) beats a stated one, and yours beats it; a learned rpm is no meter.
|
|
754
|
+
met = {"rpd": {"value": 20, "at": "2026-11-10T09:00:00+00:00"}, "rpm": {"value": 4, "at": ""}}
|
|
755
|
+
g = caps("router.requesty.ai", {}, {"rpd": said}, day, lambda: [], now, met)
|
|
756
|
+
assert len(g) == 1 and g[0]["label"] == "learned · counted by the bench" and "2026-11-10" in g[0]["title"] \
|
|
757
|
+
and g[0]["windows"][0]["of"] == 20 and g[0]["windows"][0]["remaining"] == 18, g
|
|
758
|
+
g = caps("router.requesty.ai", {"router.requesty.ai": {"rpd": 10}}, {}, day, lambda: [], now, met)
|
|
759
|
+
assert len(g) == 1 and g[0]["label"] == "your cap · counted by the bench", g
|
|
746
760
|
asked = []
|
|
747
761
|
g = caps("api.cohere.ai", {}, {"rpmo": (1000, "its replies", "")}, day, lambda: asked.append(1) or day, now)
|
|
748
762
|
assert asked and g[0]["windows"][0]["remaining"] == 999 and g[0]["windows"][0]["label"] == "requests · month", g
|
package/metrics/test_decide.py
CHANGED
|
@@ -29,7 +29,9 @@ def jsonl(path: str, rows: list[dict]) -> None:
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
write("config/endpoints.json", {"hub": {"base": "http://127.0.0.1:9/api", "key": "hub", "default": True,
|
|
32
|
-
"decisions": "http://127.0.0.1:9/openai/{path}"}
|
|
32
|
+
"decisions": "http://127.0.0.1:9/openai/{path}"},
|
|
33
|
+
"cf": {"base": "http://127.0.0.1:9/ai/v1", "key": "hub",
|
|
34
|
+
"decisions": "http://127.0.0.1:9/ai/run/{model}"}})
|
|
33
35
|
# dm2 first: before any result it is the one asked (the first that takes choice, as before),
|
|
34
36
|
# except per request, where its :free budget is spared while dm1 can answer.
|
|
35
37
|
MODELS = {
|
|
@@ -91,6 +93,14 @@ assert "passthrough" in decide.error_text(http_error(b'{"detail": "Direct API pa
|
|
|
91
93
|
assert decide.error_text(http_error(b'{"error": null, "message": "down"}')).startswith("400")
|
|
92
94
|
assert decide.error_text(http_error(b"not json")) == "400"
|
|
93
95
|
|
|
96
|
+
# A chat refusal says "decision model" however the provider words it.
|
|
97
|
+
assert decide.names_decisions("400 is a decisions model and cannot be used with chat") # OpenRouter
|
|
98
|
+
assert decide.names_decisions("400 {'errors': [{'message': \"AiError: Bad input: Error: required properties"
|
|
99
|
+
" at '/' are 'model,state,questions' (72ae)\", 'code': 5006}]}") # Cloudflare
|
|
100
|
+
assert decide.names_decisions("400 Model execution failed (User Input Error): Required value missing: questions.")
|
|
101
|
+
assert not decide.names_decisions("400 messages: Required") # Cloudflare's v1 refusing a decision body
|
|
102
|
+
assert not decide.names_decisions("429 Rate limit exceeded") and not decide.names_decisions("")
|
|
103
|
+
|
|
94
104
|
# ask() refuses a type the entry does not list, before any call is made (and logs nothing).
|
|
95
105
|
try:
|
|
96
106
|
decide.ask("yesno", "s", {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}})
|
|
@@ -124,6 +134,20 @@ def urlopen(request, timeout=0):
|
|
|
124
134
|
return io.BytesIO(json.dumps({"answers": {name: answer}}).encode())
|
|
125
135
|
|
|
126
136
|
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Cloudflare: the model in the URL and not the body, the reply in a "result" wrapper.
|
|
140
|
+
def cf_urlopen(request, timeout=0):
|
|
141
|
+
body = json.loads(request.data)
|
|
142
|
+
assert request.full_url == "http://127.0.0.1:9/ai/run/@cf/cloudflare/clef-flash" and "model" not in body, body
|
|
143
|
+
return io.BytesIO(json.dumps({"result": {"model": "clef-flash", "answers": {"probe": {"type": "noul", "noul": 0.98}}},
|
|
144
|
+
"success": True, "errors": []}).encode())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
decide.urllib.request.urlopen = cf_urlopen
|
|
148
|
+
assert decide.probe("cf", "@cf/cloudflare/clef-flash") == (None, "decisions")
|
|
149
|
+
assert decide.log_rows()[-1]["answers"]["probe"]["noul"] == 0.98
|
|
150
|
+
decide.LOG.unlink()
|
|
127
151
|
decide.urllib.request.urlopen = urlopen
|
|
128
152
|
|
|
129
153
|
# ---- every call is logged, and an error's body survives the log -------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
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
5
|
"mcpName": "io.github.WendellResearch-AI/benchwarmers",
|
|
6
6
|
"license": "MIT",
|
package/scripts/onboard.py
CHANGED
|
@@ -286,7 +286,13 @@ def main() -> None:
|
|
|
286
286
|
return
|
|
287
287
|
# The passthrough hints are about Open WebUI; anywhere else a 404 is the provider's.
|
|
288
288
|
owui = endpoints.hub(endpoint) == "openwebui"
|
|
289
|
-
|
|
289
|
+
# ponytail: Cloudflare told apart by its @cf/ ids; its partner models (no @cf/) get the generic hint.
|
|
290
|
+
hint = (" Cloudflare answers decisions only on its /ai/run route, which no hub relays: add"
|
|
291
|
+
" Cloudflare as a direct endpoint (decisions .../accounts/<id>/ai/run/{model},"
|
|
292
|
+
" models_url .../ai/models/search?per_page=1000) and onboard it from there.\n"
|
|
293
|
+
if "@cf/" in name and endpoint != "cloudflare" and "{model}" not in
|
|
294
|
+
(endpoints.load()[endpoint].get("decisions") or "") else
|
|
295
|
+
" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
|
|
290
296
|
" in its environment.\n" if owui and "passthrough" in why.lower() else
|
|
291
297
|
" Its connection has no /decisions under its base. An OpenRouter-hosted decision"
|
|
292
298
|
" model cannot go through Open WebUI (its Decisions API is outside /api/v1);"
|
package/scripts/test_quota.py
CHANGED
|
@@ -20,6 +20,8 @@ not mix. Covered:
|
|
|
20
20
|
a Gemini 429 body's daily quota learned as a limit;
|
|
21
21
|
- an rpm limit across six threads at once and across two processes (calls in
|
|
22
22
|
flight count), a monthly cap (rpmo), and both refusals classed as capacity;
|
|
23
|
+
- a connection's learned limit (metrics/learn.py) enforced, its refusal not
|
|
24
|
+
learned from, your own value winning over it, and Forget;
|
|
23
25
|
- the free-tier guard holding a model that went paid, until it is allowed;
|
|
24
26
|
- metrics/cost.py reading the ledger.
|
|
25
27
|
"""
|
|
@@ -331,6 +333,31 @@ def main() -> int:
|
|
|
331
333
|
check(not bad["ok"] and serve.save_model_facts("t-ok", {"limits": {"rpmo": 5}}).get("ok") is not None
|
|
332
334
|
and "rpmo" not in ((serve.catalog.overrides().get("t-ok") or {}).get("limits") or {}),
|
|
333
335
|
"a bad rpmo is refused; a model's own limits have none", bad)
|
|
336
|
+
# A connection's learned limit (learn.py: 429s that named no number) is kept to like one of yours, which wins.
|
|
337
|
+
import datetime as dt
|
|
338
|
+
at = dt.datetime.now().astimezone()
|
|
339
|
+
learn._learn_connection("monthprov", "rpd", 3, "test", "429", at - dt.timedelta(days=2))
|
|
340
|
+
got = learn._learn_connection("monthprov", "rpd", 3, "test", "429", at - dt.timedelta(days=1))
|
|
341
|
+
rule = [x for x in pacing.rules("t-month", "monthprov") if x["axis"] == "rpd"]
|
|
342
|
+
h = pacing.held("t-month", "monthprov")
|
|
343
|
+
check(got == {"rpd": 3} and len(rule) == 1 and rule[0]["scope"] == "connection" and rule[0]["limit"] == 3
|
|
344
|
+
and rule[0]["source"].startswith("learned ") and h is not None and h[0] > 3600 and "learned" in h[1],
|
|
345
|
+
"a connection's learned rpd is enforced", (got, rule, h))
|
|
346
|
+
r = client.chat("t-month", "hi", timeout=5)
|
|
347
|
+
learn.record("t-month", client.output(r, 5), "test")
|
|
348
|
+
kept = learn.connection_learned()["monthprov"]["rpd"]
|
|
349
|
+
check("rate limit (not sent" in r["error"] and "connection-learned.json" in r["error"]
|
|
350
|
+
and kept["confirmed_at"] == kept["at"] and not learn.connection_learned(pending=True),
|
|
351
|
+
"its refusal names where the limit is from, and is not learned from", (r["error"], kept))
|
|
352
|
+
view = {c["name"]: c for c in serve.connections_view()["connections"]}
|
|
353
|
+
serve.save_connection_limits("monthprov", {"rpm": 20, "rpmo": 1000, "rpd": 100})
|
|
354
|
+
rule = [x for x in pacing.rules("t-month", "monthprov") if x["axis"] == "rpd"]
|
|
355
|
+
check(view["monthprov"]["learned"] == {"rpd": {k: kept[k] for k in ("value", "at", "confirmed_at")}}
|
|
356
|
+
and len(rule) == 1 and rule[0]["limit"] == 100 and rule[0]["source"].startswith("you"),
|
|
357
|
+
"the Endpoints dialog shows it; your own value for that connection wins", (view["monthprov"], rule))
|
|
358
|
+
gone = serve.forget_connection_learned("monthprov", "rpd")
|
|
359
|
+
check(gone["ok"] and learn.connection_learned() == {} and not serve.forget_connection_learned("monthprov", "rpd")["ok"],
|
|
360
|
+
"Forget drops a learned limit", gone)
|
|
334
361
|
page = serve.quotas_page()
|
|
335
362
|
check("id='quota-data'" in page and "fakeprov" in page and "What the bench sent" in page,
|
|
336
363
|
"the Quotas page carries its tiles and the ledger's counts")
|
package/serve.py
CHANGED
|
@@ -1544,15 +1544,42 @@ def save_connection_limits(connection: str, limits: dict, who: str = "ui") -> di
|
|
|
1544
1544
|
return {"ok": True, "errors": [], "changed": ["config/connection-limits.json"]}
|
|
1545
1545
|
|
|
1546
1546
|
|
|
1547
|
+
def forget_connection_learned(connection: str, axis: str, who: str = "ui") -> dict:
|
|
1548
|
+
"""Drop what the bench learned for one connection's axis (metrics/learn.py):
|
|
1549
|
+
its limit and its candidates, so the next 429 starts over."""
|
|
1550
|
+
import learn
|
|
1551
|
+
|
|
1552
|
+
def drop(data: dict):
|
|
1553
|
+
axes = data.get(connection)
|
|
1554
|
+
old = axes.pop(axis, None) if isinstance(axes, dict) else None
|
|
1555
|
+
if axes == {}:
|
|
1556
|
+
del data[connection]
|
|
1557
|
+
return old
|
|
1558
|
+
old = locked.update(learn.CONN_LEARNED, drop, ensure_ascii=False)
|
|
1559
|
+
if old is None:
|
|
1560
|
+
return {"ok": False, "errors": [f"nothing is learned for {axis or '?'} on {connection or '?'}"]}
|
|
1561
|
+
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
1562
|
+
who, "", f"learned limits {connection}", {axis: old}, None)
|
|
1563
|
+
return {"ok": True, "errors": [], "changed": ["config/connection-learned.json"]}
|
|
1564
|
+
|
|
1565
|
+
|
|
1547
1566
|
def connections_view() -> dict:
|
|
1548
|
-
"""Each connection the chat models use, its models,
|
|
1567
|
+
"""Each connection the chat models use, its models, its limits, and what
|
|
1568
|
+
the bench learned from its 429s (`learned`: enforced where you set none;
|
|
1569
|
+
`pending`: met once, not enforced). Without the 429's text: it can carry
|
|
1570
|
+
an account id."""
|
|
1571
|
+
import learn
|
|
1549
1572
|
by = {}
|
|
1550
1573
|
for a in _chat_aliases():
|
|
1551
1574
|
by.setdefault(catalog.connection_of(a), []).append(a)
|
|
1552
1575
|
lim = catalog.connection_limits()
|
|
1576
|
+
met, once = learn.connection_learned(), learn.connection_learned(pending=True)
|
|
1553
1577
|
return {"ok": True, "errors": [], "keys": list(catalog.CONNECTION_LIMIT_KEYS),
|
|
1554
1578
|
"labels": {k: catalog.LIMIT_LABELS[k] for k in catalog.CONNECTION_LIMIT_KEYS},
|
|
1555
|
-
"connections": [{"name": c, "models": sorted(m), "limits": lim.get(c, {})
|
|
1579
|
+
"connections": [{"name": c, "models": sorted(m), "limits": lim.get(c, {}),
|
|
1580
|
+
"learned": {k: {f: ev.get(f) for f in ("value", "at", "confirmed_at")}
|
|
1581
|
+
for k, ev in met.get(c, {}).items()},
|
|
1582
|
+
"pending": once.get(c, {})}
|
|
1556
1583
|
for c, m in sorted(by.items())]}
|
|
1557
1584
|
|
|
1558
1585
|
|
|
@@ -5288,6 +5315,12 @@ def endpoints_page() -> str:
|
|
|
5288
5315
|
" popup) win, and limits the bench learns from errors only ever lower these. rpmo is calls"
|
|
5289
5316
|
" a calendar month (UTC), for a trial key's monthly cap. The built-in client keeps to them:"
|
|
5290
5317
|
" when one is used up it waits if the call can still finish in time, else it does not send.</p>"
|
|
5318
|
+
"<p class='sub'>The bench also learns a connection's own rpm and rpd: when a provider refuses"
|
|
5319
|
+
" with a 429 that names no number, the requests the bench had sent in that minute or day are the"
|
|
5320
|
+
" limit, near enough. Met once it is only noted; met again at about the same count, a minute or a"
|
|
5321
|
+
" day later, it is kept to like a limit of yours, unless you set your own here, which always wins."
|
|
5322
|
+
" A learned limit not met again for 14 days is forgotten, so a raised limit is found; Forget drops"
|
|
5323
|
+
" one now.</p>"
|
|
5291
5324
|
"<div id='l-list'></div><div class='errors' id='l-errors' role='alert'></div>"
|
|
5292
5325
|
"<div class='done' id='l-done'></div></div>")
|
|
5293
5326
|
|
|
@@ -5405,6 +5438,14 @@ def quotas_page() -> str:
|
|
|
5405
5438
|
+ "".join(f"<td class='num'>{ev['value']:,}</td>" if kk == k
|
|
5406
5439
|
else "<td class='num muted'>—</td>" for kk in keys)
|
|
5407
5440
|
+ f"<td>learned {esc(ev.get('at', '')[:10])} ({esc(ev.get('where', ''))})</td></tr>")
|
|
5441
|
+
# And per connection, from 429s that named no number, where you set none.
|
|
5442
|
+
for conn, axes in sorted(learn.connection_learned().items()):
|
|
5443
|
+
for k, ev in axes.items():
|
|
5444
|
+
if not (entered.get(conn) or {}).get(k):
|
|
5445
|
+
lim_rows.append(f"<tr><td>{esc(conn)}</td>"
|
|
5446
|
+
+ "".join(f"<td class='num'>{ev['value']:,}</td>" if kk == k
|
|
5447
|
+
else "<td class='num muted'>—</td>" for kk in keys)
|
|
5448
|
+
+ f"<td>learned {esc(ev.get('at', '')[:10])} (its 429s)</td></tr>")
|
|
5408
5449
|
sent_table = ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
|
|
5409
5450
|
+ th("Requests, this month", True) + th("Tokens, this month", True)
|
|
5410
5451
|
+ f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
|
|
@@ -5432,8 +5473,10 @@ def quotas_page() -> str:
|
|
|
5432
5473
|
+ "<details class='panel'><summary><b>Known limits</b></summary><p class='sub'>How much each"
|
|
5433
5474
|
" connection or model allows (tokens or requests per minute or day), so the bench can space its"
|
|
5434
5475
|
" calls. Two sources: what you enter per connection (Endpoints, Rate limits per connection), and"
|
|
5435
|
-
" what a provider states in a rate-limit error, which the bench writes down by itself
|
|
5436
|
-
"
|
|
5476
|
+
" what a provider states in a rate-limit error, which the bench writes down by itself (or, when the"
|
|
5477
|
+
" error names no number, works out for the connection from the requests it had sent). A model's"
|
|
5478
|
+
" own limits (its popup) win, and learned ones only ever lower a limit, until they have not been"
|
|
5479
|
+
" met again for 14 days and are forgotten.</p>"
|
|
5437
5480
|
+ ("<table><thead><tr>" + th("Connection or model") + "".join(th(k, True) for k in keys) + th("From")
|
|
5438
5481
|
+ f"</tr></thead><tbody>{''.join(lim_rows)}</tbody></table>" if lim_rows
|
|
5439
5482
|
else "<p class='muted'>None known yet.</p>")
|
|
@@ -6037,6 +6080,8 @@ class Handler(gateway.Handler):
|
|
|
6037
6080
|
"/api/own-client-free": lambda p: save_own_client_free(p.get("providers")),
|
|
6038
6081
|
"/api/connection-limits": lambda p: save_connection_limits(str(p.get("connection") or ""),
|
|
6039
6082
|
p.get("limits")),
|
|
6083
|
+
"/api/connection-learned/forget": lambda p: forget_connection_learned(
|
|
6084
|
+
str(p.get("connection") or ""), str(p.get("axis") or "")),
|
|
6040
6085
|
"/api/verdict": lambda p: record_verdict(str(p.get("call") or ""), p.get("useful"),
|
|
6041
6086
|
str(p.get("note") or ""), p.get("real"),
|
|
6042
6087
|
p.get("false")),
|
package/ui/bench.js
CHANGED
|
@@ -1545,6 +1545,29 @@
|
|
|
1545
1545
|
lab.appendChild(box); inputs.appendChild(lab);
|
|
1546
1546
|
});
|
|
1547
1547
|
left.appendChild(inputs);
|
|
1548
|
+
// What the bench worked out from this connection's 429s (learn.py):
|
|
1549
|
+
// kept to where no value of yours is set, until Forget or 14 days.
|
|
1550
|
+
res.keys.forEach(function (k) {
|
|
1551
|
+
var ev = (c.learned || {})[k], once = (c.pending || {})[k];
|
|
1552
|
+
if (!ev && !once) return;
|
|
1553
|
+
var on = function (iso) { return new Date(iso).toLocaleDateString(undefined, {day: 'numeric', month: 'short'}); };
|
|
1554
|
+
var note = el('div', {class: 'base'}, ev
|
|
1555
|
+
? 'learned ' + ev.value + ' ' + k + ' · 429s on ' + on(ev.at)
|
|
1556
|
+
+ (ev.confirmed_at && on(ev.confirmed_at) !== on(ev.at) ? ', last on ' + on(ev.confirmed_at) : '')
|
|
1557
|
+
+ ' · ' + (c.limits[k] ? 'not enforced: yours wins' : 'enforced (yours wins)') + ' '
|
|
1558
|
+
: 'met once: ' + once.n + ' ' + k + ' · a 429 on ' + on(once.at) + ' · not enforced unless met again ');
|
|
1559
|
+
var forget = el('button', {class: 'btn small', type: 'button',
|
|
1560
|
+
'aria-label': 'Forget the learned ' + k + ' for ' + c.name}, 'Forget');
|
|
1561
|
+
forget.addEventListener('click', function () {
|
|
1562
|
+
$('l-errors').textContent = ''; $('l-done').textContent = '';
|
|
1563
|
+
post('/api/connection-learned/forget', {connection: c.name, axis: k}).then(function (r) {
|
|
1564
|
+
if (!r.ok) { listErrors($('l-errors'), r.errors); return; }
|
|
1565
|
+
$('l-done').textContent = 'Forgot the learned ' + k + ' for ' + c.name + '.';
|
|
1566
|
+
loadLimits();
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
note.appendChild(forget); left.appendChild(note);
|
|
1570
|
+
});
|
|
1548
1571
|
row.appendChild(left);
|
|
1549
1572
|
var save = el('button', {class: 'btn small', type: 'button'}, 'Save');
|
|
1550
1573
|
save.addEventListener('click', function () {
|