@wendellresearch-ai/benchwarmers 0.6.0 → 0.6.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/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 one the provider states in
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. One exception,
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.0
1
+ 0.6.2
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
- body = json.dumps({"model": served, "state": state, "questions": questions}).encode()
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
- # OpenRouter says "is a decisions model"; other providers word it their
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
 
@@ -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); entries marked `visibility` other
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
- config/model-learned.json alias -> {context_window, limits, capabilities, served_as}
25
- with the date and the evidence for each
26
- runs/cooldowns.json alias -> {until, why}: rate-limited, come back later.
27
- Not config: it expires, and is nobody's decision.
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
- return {k: v for k, v in _read(LEARNED).items() if not k.startswith("_")}
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 learned."""
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(catalog.connection_of(alias), {}).get(axis)]
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
- if n < (_known(alias, "", axis) or 1 << 62):
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)):
@@ -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) -> list[dict]:
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 `stated` ({axis: (limit, source,
519
- detail)}: what its replies say with nothing left given, or NVIDIA's site).
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/route.py CHANGED
@@ -148,12 +148,14 @@ def _full_tag_of(alias: str) -> str:
148
148
  import endpoints
149
149
  # Tags are keyed by the served name. A decision model is not in the llm
150
150
  # yaml, so its served name comes from config/decision-models.json.
151
- served = (_registered_names().get(alias)
152
- or (_json(DATA / "config" / "decision-models.json").get(alias) or {}).get("served")
153
- or alias)
151
+ decision = _json(DATA / "config" / "decision-models.json").get(alias) or {}
152
+ served = _registered_names().get(alias) or decision.get("served") or alias
154
153
  tag = _json(DATA / "config" / "provider-tags.json").get(served)
155
154
  if not tag:
156
- name = endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
155
+ # A decision model is in no registration either: its own endpoint is its `via`
156
+ # (Cloudflare's clef, onboarded from a direct cloudflare endpoint).
157
+ name = (endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
158
+ or decision.get("via") or decision.get("endpoint"))
157
159
  tag = name if name and not endpoints.hub(name) else None
158
160
  return tag or "?"
159
161
 
@@ -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.0",
3
+ "version": "0.6.2",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
@@ -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
- hint = (" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
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);"
@@ -236,6 +236,11 @@ def owner(fake: str) -> None:
236
236
  assert tags["fast-1"] == "groq" and tags["llm7.codestral"] == "llm7-api" and tags["vendor/x:free"] == "openrouter", tags
237
237
  assert "never-read" not in json.dumps(tags)
238
238
  assert route.connection_of("fast-1") == "groq" and route.connection_of("x-free") == "openrouter"
239
+ # A decision model onboarded from the direct endpoint: in no registration, untagged, its `via` is its connection.
240
+ write("decision-models.json", {"dec-1": {"served": "vendor/decide:free", "endpoint": "openrouter", "via": "openrouter"},
241
+ "dec-hub": {"served": "hub-decide", "endpoint": "openwebui", "via": "openwebui"}})
242
+ assert route.connection_of("dec-1") == "openrouter" and route.connection_of("dec-hub") == "?"
243
+ (TMP / "config" / "decision-models.json").unlink()
239
244
 
240
245
  # The default hub is recorded whole; the direct endpoint, its registered models only.
241
246
  assert check_deprecations.served() == {"fast-1", "llm7.codestral", "fresh-2", "mercury-decide",
@@ -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, and its limits."""
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
 
@@ -4929,11 +4956,12 @@ def move_card() -> str:
4929
4956
  """Settings' Export and import (export_zip, import_zip; bench.js)."""
4930
4957
  return ("<div class='panel form-panel'><h2>Export and import</h2><p class='muted'>Take this install to another"
4931
4958
  " machine, or keep a copy of it: one .zip file, imported on the other install's Settings page.</p>"
4932
- "<div class='pick'><label class='check'><input type='radio' name='x-scope' value='settings' checked"
4959
+ # No wrapper: `.pick` is the onboarding picker's 3-column grid row, and squeezed these into it.
4960
+ "<label class='check'><input type='radio' name='x-scope' value='settings' checked"
4933
4961
  " data-kept> <span><b>Settings only</b>: endpoints, collections, roles, model options, the schedule,"
4934
4962
  " gateway keys, the theme (<code>config/</code>)</span></label>"
4935
4963
  "<label class='check'><input type='radio' name='x-scope' value='all' data-kept> <span><b>Everything</b>:"
4936
- " the settings, and every result, history, log and saved answer the bench judges by</span></label></div>"
4964
+ " the settings, and every result, history, log and saved answer the bench judges by</span></label>"
4937
4965
  "<label class='check'><input type='checkbox' id='x-keys' data-kept> <span>With the endpoints' API keys."
4938
4966
  " The file then holds them: keep it like a password, and delete it once imported.</span></label>"
4939
4967
  "<div class='actions'><button class='btn' type='button' id='x-export'>Download export</button></div>"
@@ -4978,23 +5006,28 @@ def access_card() -> str:
4978
5006
  body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td>{esc(v)}</td>"
4979
5007
  f"<td class='mono set-by'>{'<br>'.join(esc(s) for s in src.split(', '))}</td></tr>"
4980
5008
  for k, v, src in rows)
4981
- running, disk = on_disk()
4982
- now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
4983
- f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
4984
5009
  return ("<div class='panel'><h2>Server and access</h2><p class='muted'>How this install is reached and who"
4985
5010
  " may call it. Set in the environment, read at start: change one and restart. Shown here, never"
4986
5011
  " set here, since a page that could change its own login could open itself to anyone. No secret"
4987
5012
  " is shown, only whether one is set.</p>"
4988
5013
  f"<table class='facts'><thead><tr>{th('Setting')}{th('Now')}{th('Set by')}</tr></thead>"
4989
- f"<tbody>{body}</tbody></table>"
4990
- # restart(): stopped and started again by serve.py's own parent process.
4991
- f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
4992
- " for an update or a setting"
4993
- " above, whatever runs it (a service, the launcher, a container or a terminal). It waits for"
4994
- " onboarding, a scheduled job and gateway calls in progress, or cuts them off if you insist; the"
4995
- " page comes back in a few seconds.</p>"
4996
- "<p><button class='btn' type='button' id='restart'>Restart Benchwarmers</button>"
4997
- " <span id='restart-out' role='status'></span></p></div>")
5014
+ f"<tbody>{body}</tbody></table>{restart_block('restart', 'a setting above')}</div>")
5015
+
5016
+
5017
+ def restart_block(id_: str, what: str) -> str:
5018
+ """The Restart heading, what runs now, and its button (bench.js restart()).
5019
+ On Settings (Server and access) and Info, each with its own ids, since both
5020
+ tabs can be in the page at once."""
5021
+ running, disk = on_disk()
5022
+ now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
5023
+ f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
5024
+ # restart(): stopped and started again by serve.py's own parent process.
5025
+ return (f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
5026
+ f" for an update or {what}, whatever runs it (a service, the launcher, a container or a terminal)."
5027
+ " It waits for onboarding, a scheduled job and gateway calls in progress, or cuts them off if you"
5028
+ " insist; the page comes back in a few seconds.</p>"
5029
+ f"<p><button class='btn' type='button' id='{id_}'>Restart Benchwarmers</button>"
5030
+ f" <span id='{id_}-out' role='status'></span></p>")
4998
5031
 
4999
5032
 
5000
5033
  RESTART = 75 # the exit code serve.py's parent reads as "start me again"
@@ -5288,6 +5321,12 @@ def endpoints_page() -> str:
5288
5321
  " popup) win, and limits the bench learns from errors only ever lower these. rpmo is calls"
5289
5322
  " a calendar month (UTC), for a trial key's monthly cap. The built-in client keeps to them:"
5290
5323
  " when one is used up it waits if the call can still finish in time, else it does not send.</p>"
5324
+ "<p class='sub'>The bench also learns a connection's own rpm and rpd: when a provider refuses"
5325
+ " with a 429 that names no number, the requests the bench had sent in that minute or day are the"
5326
+ " limit, near enough. Met once it is only noted; met again at about the same count, a minute or a"
5327
+ " day later, it is kept to like a limit of yours, unless you set your own here, which always wins."
5328
+ " A learned limit not met again for 14 days is forgotten, so a raised limit is found; Forget drops"
5329
+ " one now.</p>"
5291
5330
  "<div id='l-list'></div><div class='errors' id='l-errors' role='alert'></div>"
5292
5331
  "<div class='done' id='l-done'></div></div>")
5293
5332
 
@@ -5304,7 +5343,8 @@ def models_page() -> str:
5304
5343
  # Every model, decision models too (connections_view is chat models only,
5305
5344
  # for what a collection may hold): they are on the bench like any other.
5306
5345
  by: dict[str, list[str]] = {}
5307
- for a in model_directory():
5346
+ directory = model_directory()
5347
+ for a in directory:
5308
5348
  by.setdefault(catalog.connection_of(a), []).append(a)
5309
5349
  for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
5310
5350
  rows = []
@@ -5315,6 +5355,9 @@ def models_page() -> str:
5315
5355
  ep = through.get(a, "?")
5316
5356
  kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
5317
5357
  if a in decisions else "")
5358
+ if directory[a].get("ad_hoc"):
5359
+ kind += " " + chip("ad hoc", "", "ad hoc only: never chosen by the bench on its own, only when you"
5360
+ " name it (a collection member, with, --model)")
5318
5361
  rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
5319
5362
  f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
5320
5363
  f"<td data-sort='{esc(ep)}'>{chip(ep, 'via', f'reached through the {ep} endpoint')}</td>"
@@ -5405,6 +5448,14 @@ def quotas_page() -> str:
5405
5448
  + "".join(f"<td class='num'>{ev['value']:,}</td>" if kk == k
5406
5449
  else "<td class='num muted'>—</td>" for kk in keys)
5407
5450
  + f"<td>learned {esc(ev.get('at', '')[:10])} ({esc(ev.get('where', ''))})</td></tr>")
5451
+ # And per connection, from 429s that named no number, where you set none.
5452
+ for conn, axes in sorted(learn.connection_learned().items()):
5453
+ for k, ev in axes.items():
5454
+ if not (entered.get(conn) or {}).get(k):
5455
+ lim_rows.append(f"<tr><td>{esc(conn)}</td>"
5456
+ + "".join(f"<td class='num'>{ev['value']:,}</td>" if kk == k
5457
+ else "<td class='num muted'>—</td>" for kk in keys)
5458
+ + f"<td>learned {esc(ev.get('at', '')[:10])} (its 429s)</td></tr>")
5408
5459
  sent_table = ("<table><thead><tr>" + th("Connection") + th("Requests, 24 h", True) + th("Tokens, 24 h", True)
5409
5460
  + th("Requests, this month", True) + th("Tokens, this month", True)
5410
5461
  + f"</tr></thead><tbody>{''.join(sent_rows)}</tbody></table>"
@@ -5432,8 +5483,10 @@ def quotas_page() -> str:
5432
5483
  + "<details class='panel'><summary><b>Known limits</b></summary><p class='sub'>How much each"
5433
5484
  " connection or model allows (tokens or requests per minute or day), so the bench can space its"
5434
5485
  " 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. A model's"
5436
- " own limits (its popup) win, and learned ones only ever lower a limit.</p>"
5486
+ " what a provider states in a rate-limit error, which the bench writes down by itself (or, when the"
5487
+ " error names no number, works out for the connection from the requests it had sent). A model's"
5488
+ " own limits (its popup) win, and learned ones only ever lower a limit, until they have not been"
5489
+ " met again for 14 days and are forgotten.</p>"
5437
5490
  + ("<table><thead><tr>" + th("Connection or model") + "".join(th(k, True) for k in keys) + th("From")
5438
5491
  + f"</tr></thead><tbody>{''.join(lim_rows)}</tbody></table>" if lim_rows
5439
5492
  else "<p class='muted'>None known yet.</p>")
@@ -5561,6 +5614,7 @@ def info_page() -> str:
5561
5614
  ("Theme", saved_theme())]]
5562
5615
  body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td class='mono'>{v}</td></tr>" for k, v in rows)
5563
5616
  return (f"<div class='panel'><h2>Info</h2><table class='facts'><tbody>{body}</tbody></table>"
5617
+ f"{restart_block('restart-info', 'a setting')}"
5564
5618
  "<p class='muted'>Benchwarmers · by Wendell Research · MIT licence"
5565
5619
  " (<code>LICENSE</code>, credits in <code>NOTICE</code>). Every response carries"
5566
5620
  " <code>X-Benchwarmers-Version</code>.</p></div>")
@@ -5625,8 +5679,7 @@ def editor() -> str:
5625
5679
  + "".join(f"<label>{k} <input type='number' min='1' data-k='{k}' placeholder='—'></label>"
5626
5680
  for k in ("rpm", "rpd", "tpm", "tpd"))
5627
5681
  + "</div><div class='src' id='s-limits'></div></div>"
5628
- "</div><div class='errors' id='f-errors' role='alert'></div><div class='done' id='f-done'></div>"
5629
- "<div class='actions'><button class='btn' id='f-save' type='button'>Save facts</button></div>"
5682
+ "</div><div class='errors' id='f-errors' role='alert'></div>"
5630
5683
  "</details>"
5631
5684
  # How the bench calls it (serve.save_model_options): every caller, gateway, suite and probes.
5632
5685
  "<details id='m-opts' class='facts-box'><summary>Call options</summary>"
@@ -5636,8 +5689,7 @@ def editor() -> str:
5636
5689
  " makes to it. A reasoning model thinks out of the same budget and answers with an empty string"
5637
5690
  " when it runs out, which looks like a dead model: raise it then. Blank leaves it to the"
5638
5691
  " endpoint. <span id='co-ceiling'></span></p>"
5639
- "<div class='errors' id='co-errors' role='alert'></div><div class='done' id='co-done'></div>"
5640
- "<div class='actions'><button class='btn' id='co-save' type='button'>Save options</button></div>"
5692
+ "<div class='errors' id='co-errors' role='alert'></div>"
5641
5693
  "</details>"
5642
5694
  "<label class='check'><input type='checkbox' id='m-adhoc'> Ad hoc only</label>"
5643
5695
  "<p class='rules'>Never chosen by the bench on its own: no explorer seat, no router"
@@ -6037,6 +6089,8 @@ class Handler(gateway.Handler):
6037
6089
  "/api/own-client-free": lambda p: save_own_client_free(p.get("providers")),
6038
6090
  "/api/connection-limits": lambda p: save_connection_limits(str(p.get("connection") or ""),
6039
6091
  p.get("limits")),
6092
+ "/api/connection-learned/forget": lambda p: forget_connection_learned(
6093
+ str(p.get("connection") or ""), str(p.get("axis") or "")),
6040
6094
  "/api/verdict": lambda p: record_verdict(str(p.get("call") or ""), p.get("useful"),
6041
6095
  str(p.get("note") or ""), p.get("real"),
6042
6096
  p.get("false")),
package/ui/bench.js CHANGED
@@ -55,30 +55,33 @@
55
55
  askD.addEventListener('close', function () { answer(false); }); // Escape
56
56
 
57
57
  // ---- restart (serve.restart): the page reloads once the new one answers ----
58
- function restart(force) {
59
- var rs = $('restart'), out = $('restart-out');
58
+ // One button on Settings (restart) and one on Info (restart-info), each with its own status line.
59
+ function restart(id, force) {
60
+ var rs = $(id), out = $(id + '-out');
60
61
  rs.disabled = true; out.textContent = 'Restarting…';
61
62
  post('/api/restart', {force: force}).then(function (res) {
62
- if (res.ok) return back(Date.now());
63
+ if (res.ok) return back(out, Date.now());
63
64
  rs.disabled = false; out.textContent = '';
64
65
  if (!res.busy) { listErrors(out, res.errors); return; }
65
66
  ask('Still in progress: ' + res.errors.join('; ') + '. Restart anyway and cut them off?', 'Restart anyway', true)
66
- .then(function (yes) { if (yes) restart(true); });
67
+ .then(function (yes) { if (yes) restart(id, true); });
67
68
  }).catch(function (err) { rs.disabled = false; out.textContent = String(err); });
68
69
  }
69
70
  // The old one stops half a second after answering; /health answers again once the new one is up.
70
- function back(since) {
71
+ function back(out, since) {
71
72
  setTimeout(function () {
72
73
  fetch('/health', {cache: 'no-store'}).then(function (r) { if (!r.ok) throw r.status; location.reload(); })
73
74
  .catch(function () {
74
- if (Date.now() - since < 120000) back(since);
75
- else $('restart-out').textContent = 'Not back after two minutes: look at whatever runs it.';
75
+ if (Date.now() - since < 120000) back(out, since);
76
+ else out.textContent = 'Not back after two minutes: look at whatever runs it.';
76
77
  });
77
78
  }, 1500);
78
79
  }
79
- on('click', 'restart', function () {
80
- ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
81
- .then(function (yes) { if (yes) restart(false); });
80
+ ['restart', 'restart-info'].forEach(function (id) {
81
+ on('click', id, function () {
82
+ ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
83
+ .then(function (yes) { if (yes) restart(id, false); });
84
+ });
82
85
  });
83
86
 
84
87
  // ---- Export and import (serve.export_zip, import_zip) ----------------
@@ -490,7 +493,7 @@
490
493
  reason: $('m-reason'), remove: $('m-remove'), onboard: $('m-onboard'),
491
494
  qtypes: $('m-qtypes'), aliasWhy: $('m-alias-why'), roleHint: $('m-role-hint')};
492
495
  m.aliasWhy.dataset.chat = m.aliasWhy.textContent;
493
- var editing = null;
496
+ var editing = null, shown = {};
494
497
 
495
498
  function openModel(alias) {
496
499
  var model = D.models[alias], gone = (D.retired || {})[alias];
@@ -539,6 +542,8 @@
539
542
  + (hint.at || '').slice(0, 10) + '). Only a suggestion: the role is the one you pick above.' : '';
540
543
  m.save.disabled = false; m.save.textContent = 'Save';
541
544
  m.reason.value = ''; m.removeBox.open = false; m.remove.disabled = false; refresh();
545
+ // What the popup opened with: Save sends only the parts that differ from it.
546
+ shown = {model: JSON.stringify(modelBody()), facts: factsBody(), opts: optsBody()};
542
547
  }
543
548
  if (!md.open) md.showModal(); // open already: shown again as it now is (shownAgain)
544
549
  }
@@ -577,7 +582,7 @@
577
582
  var box = $('m-facts'), f = (D.facts || {})[alias], profile = $('m-profile');
578
583
  box.hidden = profile.hidden = decision || !f;
579
584
  profile.textContent = '';
580
- $('f-errors').textContent = ''; $('f-done').textContent = '';
585
+ $('f-errors').textContent = '';
581
586
  if (box.hidden) return;
582
587
  var src = f.sources || {}, mine = function (k) { return src[k] === 'you'; };
583
588
  // At a glance, as models.dev shows a model: what goes in, what comes out.
@@ -621,40 +626,25 @@
621
626
  var lsrc = Object.keys(src).filter(function (k) { return k.indexOf('limits') === 0; }).map(function (k) { return src[k]; });
622
627
  $('s-limits').textContent = lsrc.length ? lsrc.join(' · ') : 'none known';
623
628
  }
624
- $('f-save').addEventListener('click', function () {
625
- if (!editing) return;
629
+ // The facts as typed, as /api/model-facts takes them; null when the box is not shown.
630
+ function factsBody() {
631
+ if ($('m-facts').hidden) return null;
626
632
  var facts = {limits: {}};
627
633
  FACT_NUM.forEach(function (k) { if ($('f-' + k).value) facts[k] = parseInt($('f-' + k).value, 10); });
628
634
  FACT_CAP.forEach(function (k) { var v = $('f-' + k).value; if (v) facts[k] = v === 'yes'; });
629
635
  if ($('f-knowledge_cutoff').value.trim()) facts.knowledge_cutoff = $('f-knowledge_cutoff').value.trim();
630
636
  document.querySelectorAll('#f-limits input').forEach(function (b) { if (b.value) facts.limits[b.dataset.k] = parseInt(b.value, 10); });
631
- $('f-errors').textContent = ''; $('f-done').textContent = '';
632
- var alias = editing;
633
- post('/api/model-facts', {alias: alias, facts: facts}).then(function (r) {
634
- if (!r.ok) { listErrors($('f-errors'), r.errors); return; }
635
- // The tables that show facts (At a glance, Models, Quotas) follow once the popup closes (live).
636
- shownAgain(alias, 'f-done', 'Saved. The tables show it once this closes.');
637
- });
638
- });
637
+ return JSON.stringify(facts);
638
+ }
639
639
  // ---- call options: how the bench asks this model (serve.save_model_options)
640
640
  function fillOptions(alias, decision) {
641
641
  var o = (D.options || {})[alias] || {}, ceiling = ((D.facts || {})[alias] || {}).max_output;
642
642
  $('m-opts').hidden = decision;
643
- $('co-errors').textContent = ''; $('co-done').textContent = '';
643
+ $('co-errors').textContent = '';
644
644
  $('co-max').value = o.max_tokens || ''; $('co-max').placeholder = "the endpoint's default";
645
645
  $('co-ceiling').textContent = ceiling ? 'At most its max output, ' + Number(ceiling).toLocaleString() + '.' : '';
646
646
  }
647
- $('co-save').addEventListener('click', function () {
648
- if (!editing) return;
649
- var v = $('co-max').value.trim();
650
- $('co-errors').textContent = ''; $('co-done').textContent = 'Saving…'; // the llm CLI is told too: seconds
651
- var alias = editing;
652
- post('/api/model-options', {alias: alias, max_tokens: v ? Number(v) : null}).then(function (r) {
653
- if (!r.ok) { $('co-done').textContent = ''; listErrors($('co-errors'), r.errors); return; }
654
- ownChange(); // only this popup shows them
655
- shownAgain(alias, 'co-done', 'Saved' + (r.note ? '; ' + r.note : '') + '.');
656
- });
657
- });
647
+ function optsBody() { return $('m-opts').hidden ? null : $('co-max').value.trim(); }
658
648
 
659
649
  function refresh() {
660
650
  m.danger.hidden = !(editing && m.alias.value.trim() !== editing);
@@ -690,22 +680,57 @@
690
680
  then(res);
691
681
  };
692
682
  }
693
- m.save.addEventListener('click', function () {
694
- if (!editing) return;
695
- var body = {alias: editing, new_alias: m.alias.value.trim(), new_role: m.role.value, new_ad_hoc: m.adhoc.checked};
683
+ function modelBody() {
684
+ var body = {new_alias: m.alias.value.trim(), new_role: m.role.value, new_ad_hoc: m.adhoc.checked};
696
685
  if (!m.qtypes.hidden) {
697
686
  body.new_question_types = Array.prototype.filter.call(m.qtypes.querySelectorAll('input'),
698
687
  function (c) { return c.checked; }).map(function (c) { return c.value; });
699
688
  }
700
- (body.new_alias === editing ? Promise.resolve(true)
701
- : ask('Rename ' + editing + ' to ' + body.new_alias + '? Every record keyed by the old alias follows it.', 'Rename')
689
+ return body;
690
+ }
691
+ // The one Save: facts, then call options, then the model itself (alias, role, ad hoc,
692
+ // question types), each only if changed. The model goes last: a rename moves the
693
+ // facts and options saved under the old alias with it. The first refusal stops it.
694
+ m.save.addEventListener('click', function () {
695
+ if (!editing) return;
696
+ var alias = editing, body = modelBody(), facts = factsBody(), opts = optsBody(), said = [];
697
+ var steps = [];
698
+ if (facts !== shown.facts) steps.push(function () {
699
+ return post('/api/model-facts', {alias: alias, facts: JSON.parse(facts)}).then(function (r) {
700
+ if (!r.ok) { listErrors($('f-errors'), r.errors); $('m-facts').open = true; throw 'refused'; }
701
+ said.push('model facts');
702
+ });
703
+ });
704
+ if (opts !== shown.opts) steps.push(function () { // the llm CLI is told too: seconds
705
+ return post('/api/model-options', {alias: alias, max_tokens: opts ? Number(opts) : null}).then(function (r) {
706
+ if (!r.ok) { listErrors($('co-errors'), r.errors); $('m-opts').open = true; throw 'refused'; }
707
+ ownChange(); // only this popup shows them
708
+ said.push('call options' + (r.note ? ' (' + r.note + ')' : ''));
709
+ });
710
+ });
711
+ if (JSON.stringify(body) !== shown.model) steps.push(function () {
712
+ body.alias = alias;
713
+ return post('/api/model', body).then(function (r) {
714
+ if (!r.ok) { listErrors(m.errors, r.errors); throw 'refused'; }
715
+ said.push('the model (' + done(r).replace(/^Done: |\.$/g, '') + ')');
716
+ if (body.new_alias !== alias) { m.title.textContent = body.new_alias; refreshPanes(); }
717
+ });
718
+ });
719
+ m.errors.textContent = ''; m.done.textContent = ''; $('f-errors').textContent = ''; $('co-errors').textContent = '';
720
+ if (!steps.length) { m.done.textContent = 'Nothing changed.'; return; }
721
+ (body.new_alias === alias ? Promise.resolve(true)
722
+ : ask('Rename ' + alias + ' to ' + body.new_alias + '? Every record keyed by the old alias follows it.', 'Rename')
702
723
  ).then(function (yes) {
703
724
  if (!yes) return;
704
- m.save.disabled = true; m.save.textContent = 'Saving…'; m.errors.textContent = '';
705
- post('/api/model', body).then(after(m.save, 'Save', function (res) {
706
- if (body.new_alias !== body.alias) { m.title.textContent = body.new_alias; refreshPanes(); }
707
- shownAgain(body.new_alias, 'm-done', done(res));
708
- })).catch(function (err) { m.errors.textContent = String(err); m.save.disabled = false; m.save.textContent = 'Save'; });
725
+ m.save.disabled = true; m.save.textContent = 'Saving…';
726
+ steps.reduce(function (p, step) { return p.then(step); }, Promise.resolve()).then(function () {
727
+ // The tables that show facts follow once the popup closes (live).
728
+ shownAgain(body.new_alias, 'm-done', 'Saved: ' + said.join(' · ') + '.');
729
+ }).catch(function (err) {
730
+ if (err !== 'refused') m.errors.textContent = String(err);
731
+ if (said.length) m.done.textContent = 'Saved before that: ' + said.join(' · ') + '.';
732
+ m.save.disabled = false; m.save.textContent = 'Save';
733
+ });
709
734
  });
710
735
  });
711
736
  // The confirm is the last word: the popup closes at once and the page
@@ -1545,6 +1570,29 @@
1545
1570
  lab.appendChild(box); inputs.appendChild(lab);
1546
1571
  });
1547
1572
  left.appendChild(inputs);
1573
+ // What the bench worked out from this connection's 429s (learn.py):
1574
+ // kept to where no value of yours is set, until Forget or 14 days.
1575
+ res.keys.forEach(function (k) {
1576
+ var ev = (c.learned || {})[k], once = (c.pending || {})[k];
1577
+ if (!ev && !once) return;
1578
+ var on = function (iso) { return new Date(iso).toLocaleDateString(undefined, {day: 'numeric', month: 'short'}); };
1579
+ var note = el('div', {class: 'base'}, ev
1580
+ ? 'learned ' + ev.value + ' ' + k + ' · 429s on ' + on(ev.at)
1581
+ + (ev.confirmed_at && on(ev.confirmed_at) !== on(ev.at) ? ', last on ' + on(ev.confirmed_at) : '')
1582
+ + ' · ' + (c.limits[k] ? 'not enforced: yours wins' : 'enforced (yours wins)') + ' '
1583
+ : 'met once: ' + once.n + ' ' + k + ' · a 429 on ' + on(once.at) + ' · not enforced unless met again ');
1584
+ var forget = el('button', {class: 'btn small', type: 'button',
1585
+ 'aria-label': 'Forget the learned ' + k + ' for ' + c.name}, 'Forget');
1586
+ forget.addEventListener('click', function () {
1587
+ $('l-errors').textContent = ''; $('l-done').textContent = '';
1588
+ post('/api/connection-learned/forget', {connection: c.name, axis: k}).then(function (r) {
1589
+ if (!r.ok) { listErrors($('l-errors'), r.errors); return; }
1590
+ $('l-done').textContent = 'Forgot the learned ' + k + ' for ' + c.name + '.';
1591
+ loadLimits();
1592
+ });
1593
+ });
1594
+ note.appendChild(forget); left.appendChild(note);
1595
+ });
1548
1596
  row.appendChild(left);
1549
1597
  var save = el('button', {class: 'btn small', type: 'button'}, 'Save');
1550
1598
  save.addEventListener('click', function () {