@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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -65
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +214 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -438
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1756 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- 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()
|