@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.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.
Files changed (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +295 -295
  22. package/metrics/client.py +394 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -579
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -65
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -106
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -81
  62. package/scripts/test_mcp.py +178 -178
  63. package/scripts/test_merged.py +214 -214
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -438
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3764
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1042
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -425
  93. package/ui/bench.js +1756 -1576
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
package/metrics/learn.py CHANGED
@@ -1,221 +1,221 @@
1
- """Learn a model's real limits from its own error messages.
2
-
3
- python metrics/learn.py # what has been learned, and active cooldowns
4
- python metrics/learn.py --selftest # canned error strings, no calls
5
-
6
- THE CATALOG IS CURATED; THE ERRORS ARE THE TRUTH
7
- ------------------------------------------------
8
- A provider that says "maximum context length is 131072 tokens" has told us its
9
- real window, whatever a catalog claims; one that says "Limit 30, Used 30 ...
10
- requests per minute" has told us its rate limit; one that says "try again in
11
- 17s" has told us when to come back. FreeLLMAPI's router (MIT, read 2026-10-17)
12
- learns exactly these at runtime, and so does this: every failed call from the
13
- perf probe, the suite (and so suite/pace.py) and the gateway passes its error
14
- text through `record()`.
15
-
16
- ONLY EVER TIGHTER
17
- -----------------
18
- A learned context window or rate limit is kept only if it is LOWER than what is
19
- known, and replaces an older learned value only when lower still. An error can
20
- prove a ceiling exists; it cannot prove the ceiling is higher than a catalog
21
- says. Values you entered in the model popup (config/model-overrides.json)
22
- still win: you can always correct a bad parse.
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.
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- import datetime as dt
33
- import json
34
- import pathlib
35
- import re
36
- import sys
37
-
38
- import locked
39
-
40
- ROOT = pathlib.Path(__file__).resolve().parent.parent
41
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
42
- LEARNED = DATA / "config" / "model-learned.json"
43
- COOLDOWNS = DATA / "runs" / "cooldowns.json"
44
- DEFAULT_COOLDOWN_S = 1800 # a 429 with no stated wait
45
- MAX_COOLDOWN_S = 24 * 3600
46
-
47
- CONTEXT = [
48
- r"maximum context length is\s*([\d,]+)",
49
- r"context length(?: of| is)?\s*([\d,]+)",
50
- r"context window(?: of| is)?\s*([\d,]+)",
51
- r"max_model_len[^\d]{0,20}([\d,]+)",
52
- r"maximum (?:input|prompt) (?:length|tokens?)(?: is)?\s*([\d,]+)",
53
- ]
54
- AXES = {"rpm": r"requests? per minute|\brpm\b",
55
- "rpd": r"requests? per day|\brpd\b",
56
- "tpm": r"tokens? per minute|\btpm\b",
57
- "tpd": r"tokens? per day|\btpd\b"}
58
- RETRY = [
59
- r"retry-after[\"':\s]+(\d+(?:\.\d+)?)",
60
- r"\"retry_?delay\"\s*:\s*\"(\d+(?:\.\d+)?)s",
61
- r"\"retry_after\"\s*:\s*(\d+(?:\.\d+)?)",
62
- r"(?:try|retry) again in\s*(\d+(?:\.\d+)?)\s*(s|sec|seconds?|m|min|minutes?|h|hours?)\b",
63
- r"please retry in\s*(\d+(?:\.\d+)?)\s*(s|m|h)",
64
- ]
65
- RATE_LIMITED = re.compile(r"rate.?limit|too many requests|\b429\b|quota exceeded|resource_exhausted")
66
-
67
-
68
- def _int(s: str) -> int:
69
- return int(s.replace(",", ""))
70
-
71
-
72
- def parse(text: str) -> dict:
73
- """What an error message proves: {context_window, limits{axis: n},
74
- retry_after_s, rate_limited}. Absent keys were not stated."""
75
- low = (text or "").lower()
76
- out: dict = {}
77
- for pat in CONTEXT:
78
- if (m := re.search(pat, low)) and _int(m.group(1)) >= 512:
79
- out["context_window"] = _int(m.group(1))
80
- break
81
- # "Limit 30" (or "limit: 30") only counts when one axis is named, so a
82
- # number is never filed under the wrong limit.
83
- named = [axis for axis, pat in AXES.items() if re.search(pat, low)]
84
- if len(named) == 1 and (m := re.search(r"\blimit\b[\s:=]*([\d,]+)", low)):
85
- out["limits"] = {named[0]: _int(m.group(1))}
86
- for pat in RETRY:
87
- if m := re.search(pat, low):
88
- n = float(m.group(1))
89
- unit = (m.group(2) if m.lastindex and m.lastindex >= 2 else "s")[0]
90
- out["retry_after_s"] = min(n * {"s": 1, "m": 60, "h": 3600}[unit], MAX_COOLDOWN_S)
91
- break
92
- out["rate_limited"] = bool(RATE_LIMITED.search(low)) or "retry_after_s" in out
93
- return out
94
-
95
-
96
- _read = locked.read_json
97
-
98
-
99
- def learned() -> dict:
100
- return {k: v for k, v in _read(LEARNED).items() if not k.startswith("_")}
101
-
102
-
103
- def _known(alias: str, field: str, axis: str | None = None):
104
- """The value a learned figure has to beat: the catalog's, else the last learned."""
105
- import catalog
106
- auto = catalog.catalog().get(alias, {})
107
- mine = learned().get(alias, {})
108
- if axis:
109
- vals = [(mine.get("limits") or {}).get(axis, {}).get("value"),
110
- catalog.connection_limits().get(catalog.connection_of(alias), {}).get(axis)]
111
- else:
112
- vals = [(mine.get(field) or {}).get("value"), auto.get(field)]
113
- vals = [v for v in vals if v]
114
- return min(vals) if vals else None
115
-
116
-
117
- def record(alias: str, text: str, where: str) -> dict:
118
- """Learn from one failed call. Returns what was stored or set, for logging."""
119
- found = parse(text)
120
- now = dt.datetime.now().astimezone()
121
- stamp = now.isoformat(timespec="seconds")
122
- evidence = " ".join((text or "").split())[:200]
123
- stored: dict = {}
124
-
125
- def tighten(data: dict) -> None:
126
- # Compared inside the lock, so two seats learning at once both count.
127
- entry = data.get(alias, {})
128
- if (n := found.get("context_window")) and n < (_known(alias, "context_window") or 1 << 62):
129
- entry["context_window"] = {"value": n, "at": stamp, "where": where, "evidence": evidence}
130
- stored["context_window"] = n
131
- for axis, n in (found.get("limits") or {}).items():
132
- if n < (_known(alias, "", axis) or 1 << 62):
133
- entry.setdefault("limits", {})[axis] = {"value": n, "at": stamp, "where": where,
134
- "evidence": evidence}
135
- stored[axis] = n
136
- if stored:
137
- data[alias] = entry
138
- data.setdefault("_why", "Learned from error messages by metrics/learn.py; only ever"
139
- " tightened. Your values in config/model-overrides.json win.")
140
-
141
- if alias and (found.get("context_window") or found.get("limits")):
142
- locked.update(LEARNED, tighten, ensure_ascii=False)
143
- if alias and found.get("rate_limited"):
144
- wait = found.get("retry_after_s") or DEFAULT_COOLDOWN_S
145
- cool = {"until": (now + dt.timedelta(seconds=wait)).isoformat(timespec="seconds"),
146
- "why": evidence, "where": where, "stated": "retry_after_s" in found}
147
- locked.update(COOLDOWNS, lambda cd: cd.__setitem__(alias, cool), ensure_ascii=False)
148
- stored["cooldown_s"] = round(wait)
149
- return stored
150
-
151
-
152
- def cooldowns() -> dict:
153
- """Active cooldowns only: alias -> {until, why}."""
154
- now = dt.datetime.now().astimezone()
155
- out = {}
156
- for alias, c in _read(COOLDOWNS).items():
157
- try:
158
- if dt.datetime.fromisoformat(c["until"]) > now:
159
- out[alias] = c
160
- except (KeyError, ValueError, TypeError):
161
- continue
162
- return out
163
-
164
-
165
- def note_capability(alias: str, cap: str, evidence: str) -> None:
166
- """A capability SEEN working (a tool call returned, reasoning tokens
167
- present). Only positives are stored: a probe that fails proves nothing."""
168
- seen = {"value": True, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
169
- "evidence": evidence[:200]}
170
- locked.update(LEARNED, lambda data: data.setdefault(alias, {}).setdefault("capabilities", {})
171
- .__setitem__(cap, seen), ensure_ascii=False)
172
-
173
-
174
- def note_served_as(alias: str, requested: str, served: str) -> None:
175
- """The response named a different model than the one asked for."""
176
- swap = {"requested": requested, "served": served,
177
- "at": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
178
- locked.update(LEARNED, lambda data: data.setdefault(alias, {}).__setitem__("served_as", swap),
179
- ensure_ascii=False)
180
-
181
-
182
- def _selftest() -> None:
183
- p = parse("Error code: 400 - This model's maximum context length is 131,072 tokens. However,"
184
- " you requested 140000 tokens")
185
- assert p["context_window"] == 131072 and not p["rate_limited"], p
186
- p = parse("429 Too Many Requests: Rate limit reached for model in organization: Limit 30,"
187
- " Used 30, Requested 1. Please try again in 2.5s. (requests per minute)")
188
- assert p["limits"] == {"rpm": 30} and p["retry_after_s"] == 2.5 and p["rate_limited"], p
189
- p = parse('{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"retryDelay":"17s"}]}}')
190
- assert p["retry_after_s"] == 17 and p["rate_limited"], p
191
- p = parse("Rate limit exceeded: free-models-per-day. Try again in 3 minutes")
192
- assert p["retry_after_s"] == 180 and "limits" not in p, p # no number under a named axis
193
- # Two axes named: the number is not filed under either.
194
- assert "limits" not in parse("limit 100 (tokens per minute) and requests per minute")
195
- assert parse("model not found") == {"rate_limited": False}
196
- assert parse("context length of 8k exceeded").get("context_window") is None # "8" < 512
197
- print("ok")
198
-
199
-
200
- def main() -> None:
201
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
202
- if "--selftest" in sys.argv:
203
- _selftest()
204
- return
205
- for alias, e in sorted(learned().items()):
206
- bits = []
207
- if e.get("context_window"):
208
- bits.append(f"context {e['context_window']['value']:,}")
209
- bits += [f"{k} {v['value']}" for k, v in (e.get("limits") or {}).items()]
210
- bits += [f"can {k}" for k in (e.get("capabilities") or {})]
211
- if e.get("served_as"):
212
- bits.append(f"served as {e['served_as']['served']}")
213
- print(f" {alias:<34} {', '.join(bits)}")
214
- for alias, c in sorted(cooldowns().items()):
215
- print(f" cooling {alias:<26} until {c['until'][11:16]} ({c['why'][:70]})")
216
-
217
-
218
- if __name__ == "__main__":
219
- sys.path.insert(0, str(ROOT / "metrics"))
220
- sys.path.insert(0, str(ROOT / "suite"))
221
- main()
1
+ """Learn a model's real limits from its own error messages.
2
+
3
+ python metrics/learn.py # what has been learned, and active cooldowns
4
+ python metrics/learn.py --selftest # canned error strings, no calls
5
+
6
+ THE CATALOG IS CURATED; THE ERRORS ARE THE TRUTH
7
+ ------------------------------------------------
8
+ A provider that says "maximum context length is 131072 tokens" has told us its
9
+ real window, whatever a catalog claims; one that says "Limit 30, Used 30 ...
10
+ requests per minute" has told us its rate limit; one that says "try again in
11
+ 17s" has told us when to come back. FreeLLMAPI's router (MIT, read 2026-10-17)
12
+ learns exactly these at runtime, and so does this: every failed call from the
13
+ perf probe, the suite (and so suite/pace.py) and the gateway passes its error
14
+ text through `record()`.
15
+
16
+ ONLY EVER TIGHTER
17
+ -----------------
18
+ A learned context window or rate limit is kept only if it is LOWER than what is
19
+ known, and replaces an older learned value only when lower still. An error can
20
+ prove a ceiling exists; it cannot prove the ceiling is higher than a catalog
21
+ says. Values you entered in the model popup (config/model-overrides.json)
22
+ still win: you can always correct a bad parse.
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.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import datetime as dt
33
+ import json
34
+ import pathlib
35
+ import re
36
+ import sys
37
+
38
+ import locked
39
+
40
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
41
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
42
+ LEARNED = DATA / "config" / "model-learned.json"
43
+ COOLDOWNS = DATA / "runs" / "cooldowns.json"
44
+ DEFAULT_COOLDOWN_S = 1800 # a 429 with no stated wait
45
+ MAX_COOLDOWN_S = 24 * 3600
46
+
47
+ CONTEXT = [
48
+ r"maximum context length is\s*([\d,]+)",
49
+ r"context length(?: of| is)?\s*([\d,]+)",
50
+ r"context window(?: of| is)?\s*([\d,]+)",
51
+ r"max_model_len[^\d]{0,20}([\d,]+)",
52
+ r"maximum (?:input|prompt) (?:length|tokens?)(?: is)?\s*([\d,]+)",
53
+ ]
54
+ AXES = {"rpm": r"requests? per minute|\brpm\b",
55
+ "rpd": r"requests? per day|\brpd\b",
56
+ "tpm": r"tokens? per minute|\btpm\b",
57
+ "tpd": r"tokens? per day|\btpd\b"}
58
+ RETRY = [
59
+ r"retry-after[\"':\s]+(\d+(?:\.\d+)?)",
60
+ r"\"retry_?delay\"\s*:\s*\"(\d+(?:\.\d+)?)s",
61
+ r"\"retry_after\"\s*:\s*(\d+(?:\.\d+)?)",
62
+ r"(?:try|retry) again in\s*(\d+(?:\.\d+)?)\s*(s|sec|seconds?|m|min|minutes?|h|hours?)\b",
63
+ r"please retry in\s*(\d+(?:\.\d+)?)\s*(s|m|h)",
64
+ ]
65
+ RATE_LIMITED = re.compile(r"rate.?limit|too many requests|\b429\b|quota exceeded|resource_exhausted")
66
+
67
+
68
+ def _int(s: str) -> int:
69
+ return int(s.replace(",", ""))
70
+
71
+
72
+ def parse(text: str) -> dict:
73
+ """What an error message proves: {context_window, limits{axis: n},
74
+ retry_after_s, rate_limited}. Absent keys were not stated."""
75
+ low = (text or "").lower()
76
+ out: dict = {}
77
+ for pat in CONTEXT:
78
+ if (m := re.search(pat, low)) and _int(m.group(1)) >= 512:
79
+ out["context_window"] = _int(m.group(1))
80
+ break
81
+ # "Limit 30" (or "limit: 30") only counts when one axis is named, so a
82
+ # number is never filed under the wrong limit.
83
+ named = [axis for axis, pat in AXES.items() if re.search(pat, low)]
84
+ if len(named) == 1 and (m := re.search(r"\blimit\b[\s:=]*([\d,]+)", low)):
85
+ out["limits"] = {named[0]: _int(m.group(1))}
86
+ for pat in RETRY:
87
+ if m := re.search(pat, low):
88
+ n = float(m.group(1))
89
+ unit = (m.group(2) if m.lastindex and m.lastindex >= 2 else "s")[0]
90
+ out["retry_after_s"] = min(n * {"s": 1, "m": 60, "h": 3600}[unit], MAX_COOLDOWN_S)
91
+ break
92
+ out["rate_limited"] = bool(RATE_LIMITED.search(low)) or "retry_after_s" in out
93
+ return out
94
+
95
+
96
+ _read = locked.read_json
97
+
98
+
99
+ def learned() -> dict:
100
+ return {k: v for k, v in _read(LEARNED).items() if not k.startswith("_")}
101
+
102
+
103
+ def _known(alias: str, field: str, axis: str | None = None):
104
+ """The value a learned figure has to beat: the catalog's, else the last learned."""
105
+ import catalog
106
+ auto = catalog.catalog().get(alias, {})
107
+ mine = learned().get(alias, {})
108
+ if axis:
109
+ vals = [(mine.get("limits") or {}).get(axis, {}).get("value"),
110
+ catalog.connection_limits().get(catalog.connection_of(alias), {}).get(axis)]
111
+ else:
112
+ vals = [(mine.get(field) or {}).get("value"), auto.get(field)]
113
+ vals = [v for v in vals if v]
114
+ return min(vals) if vals else None
115
+
116
+
117
+ def record(alias: str, text: str, where: str) -> dict:
118
+ """Learn from one failed call. Returns what was stored or set, for logging."""
119
+ found = parse(text)
120
+ now = dt.datetime.now().astimezone()
121
+ stamp = now.isoformat(timespec="seconds")
122
+ evidence = " ".join((text or "").split())[:200]
123
+ stored: dict = {}
124
+
125
+ def tighten(data: dict) -> None:
126
+ # Compared inside the lock, so two seats learning at once both count.
127
+ entry = data.get(alias, {})
128
+ if (n := found.get("context_window")) and n < (_known(alias, "context_window") or 1 << 62):
129
+ entry["context_window"] = {"value": n, "at": stamp, "where": where, "evidence": evidence}
130
+ stored["context_window"] = n
131
+ for axis, n in (found.get("limits") or {}).items():
132
+ if n < (_known(alias, "", axis) or 1 << 62):
133
+ entry.setdefault("limits", {})[axis] = {"value": n, "at": stamp, "where": where,
134
+ "evidence": evidence}
135
+ stored[axis] = n
136
+ if stored:
137
+ data[alias] = entry
138
+ data.setdefault("_why", "Learned from error messages by metrics/learn.py; only ever"
139
+ " tightened. Your values in config/model-overrides.json win.")
140
+
141
+ if alias and (found.get("context_window") or found.get("limits")):
142
+ locked.update(LEARNED, tighten, ensure_ascii=False)
143
+ if alias and found.get("rate_limited"):
144
+ wait = found.get("retry_after_s") or DEFAULT_COOLDOWN_S
145
+ cool = {"until": (now + dt.timedelta(seconds=wait)).isoformat(timespec="seconds"),
146
+ "why": evidence, "where": where, "stated": "retry_after_s" in found}
147
+ locked.update(COOLDOWNS, lambda cd: cd.__setitem__(alias, cool), ensure_ascii=False)
148
+ stored["cooldown_s"] = round(wait)
149
+ return stored
150
+
151
+
152
+ def cooldowns() -> dict:
153
+ """Active cooldowns only: alias -> {until, why}."""
154
+ now = dt.datetime.now().astimezone()
155
+ out = {}
156
+ for alias, c in _read(COOLDOWNS).items():
157
+ try:
158
+ if dt.datetime.fromisoformat(c["until"]) > now:
159
+ out[alias] = c
160
+ except (KeyError, ValueError, TypeError):
161
+ continue
162
+ return out
163
+
164
+
165
+ def note_capability(alias: str, cap: str, evidence: str) -> None:
166
+ """A capability SEEN working (a tool call returned, reasoning tokens
167
+ present). Only positives are stored: a probe that fails proves nothing."""
168
+ seen = {"value": True, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
169
+ "evidence": evidence[:200]}
170
+ locked.update(LEARNED, lambda data: data.setdefault(alias, {}).setdefault("capabilities", {})
171
+ .__setitem__(cap, seen), ensure_ascii=False)
172
+
173
+
174
+ def note_served_as(alias: str, requested: str, served: str) -> None:
175
+ """The response named a different model than the one asked for."""
176
+ swap = {"requested": requested, "served": served,
177
+ "at": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
178
+ locked.update(LEARNED, lambda data: data.setdefault(alias, {}).__setitem__("served_as", swap),
179
+ ensure_ascii=False)
180
+
181
+
182
+ def _selftest() -> None:
183
+ p = parse("Error code: 400 - This model's maximum context length is 131,072 tokens. However,"
184
+ " you requested 140000 tokens")
185
+ assert p["context_window"] == 131072 and not p["rate_limited"], p
186
+ p = parse("429 Too Many Requests: Rate limit reached for model in organization: Limit 30,"
187
+ " Used 30, Requested 1. Please try again in 2.5s. (requests per minute)")
188
+ assert p["limits"] == {"rpm": 30} and p["retry_after_s"] == 2.5 and p["rate_limited"], p
189
+ p = parse('{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"retryDelay":"17s"}]}}')
190
+ assert p["retry_after_s"] == 17 and p["rate_limited"], p
191
+ p = parse("Rate limit exceeded: free-models-per-day. Try again in 3 minutes")
192
+ assert p["retry_after_s"] == 180 and "limits" not in p, p # no number under a named axis
193
+ # Two axes named: the number is not filed under either.
194
+ assert "limits" not in parse("limit 100 (tokens per minute) and requests per minute")
195
+ assert parse("model not found") == {"rate_limited": False}
196
+ assert parse("context length of 8k exceeded").get("context_window") is None # "8" < 512
197
+ print("ok")
198
+
199
+
200
+ def main() -> None:
201
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
202
+ if "--selftest" in sys.argv:
203
+ _selftest()
204
+ return
205
+ for alias, e in sorted(learned().items()):
206
+ bits = []
207
+ if e.get("context_window"):
208
+ bits.append(f"context {e['context_window']['value']:,}")
209
+ bits += [f"{k} {v['value']}" for k, v in (e.get("limits") or {}).items()]
210
+ bits += [f"can {k}" for k in (e.get("capabilities") or {})]
211
+ if e.get("served_as"):
212
+ bits.append(f"served as {e['served_as']['served']}")
213
+ print(f" {alias:<34} {', '.join(bits)}")
214
+ for alias, c in sorted(cooldowns().items()):
215
+ print(f" cooling {alias:<26} until {c['until'][11:16]} ({c['why'][:70]})")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ sys.path.insert(0, str(ROOT / "metrics"))
220
+ sys.path.insert(0, str(ROOT / "suite"))
221
+ main()