@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.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/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -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 +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -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 +410 -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 +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- 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 +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -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 +1757 -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,234 @@
|
|
|
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
|
|
118
|
-
"""
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def
|
|
166
|
-
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 _tighten(alias: str, found: dict, where: str, evidence: str, stamp: str) -> dict:
|
|
118
|
+
"""Store a context window or limits that are lower than what is known."""
|
|
119
|
+
stored: dict = {}
|
|
120
|
+
|
|
121
|
+
def tighten(data: dict) -> None:
|
|
122
|
+
# Compared inside the lock, so two seats learning at once both count.
|
|
123
|
+
entry = data.get(alias, {})
|
|
124
|
+
if (n := found.get("context_window")) and n < (_known(alias, "context_window") or 1 << 62):
|
|
125
|
+
entry["context_window"] = {"value": n, "at": stamp, "where": where, "evidence": evidence}
|
|
126
|
+
stored["context_window"] = n
|
|
127
|
+
for axis, n in (found.get("limits") or {}).items():
|
|
128
|
+
if n < (_known(alias, "", axis) or 1 << 62):
|
|
129
|
+
entry.setdefault("limits", {})[axis] = {"value": n, "at": stamp, "where": where,
|
|
130
|
+
"evidence": evidence}
|
|
131
|
+
stored[axis] = n
|
|
132
|
+
if stored:
|
|
133
|
+
data[alias] = entry
|
|
134
|
+
data.setdefault("_why", "Learned from error messages by metrics/learn.py; only ever"
|
|
135
|
+
" tightened. Your values in config/model-overrides.json win.")
|
|
136
|
+
|
|
137
|
+
if alias and (found.get("context_window") or found.get("limits")):
|
|
138
|
+
locked.update(LEARNED, tighten, ensure_ascii=False)
|
|
139
|
+
return stored
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def note_limits(alias: str, limits: dict, where: str, evidence: str) -> dict:
|
|
143
|
+
"""Limits a provider stated outside its message text: a 429 body's quota
|
|
144
|
+
(metrics/quota.py, Gemini's "50 a day"). Kept the same way, only tighter."""
|
|
145
|
+
stamp = dt.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
146
|
+
return _tighten(alias, {"limits": {k: v for k, v in limits.items() if k in AXES}}, where, evidence, stamp)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record(alias: str, text: str, where: str) -> dict:
|
|
150
|
+
"""Learn from one failed call. Returns what was stored or set, for logging."""
|
|
151
|
+
found = parse(text)
|
|
152
|
+
now = dt.datetime.now().astimezone()
|
|
153
|
+
stamp = now.isoformat(timespec="seconds")
|
|
154
|
+
evidence = " ".join((text or "").split())[:200]
|
|
155
|
+
stored = _tighten(alias, found, where, evidence, stamp)
|
|
156
|
+
if alias and found.get("rate_limited"):
|
|
157
|
+
wait = found.get("retry_after_s") or DEFAULT_COOLDOWN_S
|
|
158
|
+
cool = {"until": (now + dt.timedelta(seconds=wait)).isoformat(timespec="seconds"),
|
|
159
|
+
"why": evidence, "where": where, "stated": "retry_after_s" in found}
|
|
160
|
+
locked.update(COOLDOWNS, lambda cd: cd.__setitem__(alias, cool), ensure_ascii=False)
|
|
161
|
+
stored["cooldown_s"] = round(wait)
|
|
162
|
+
return stored
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cooldowns() -> dict:
|
|
166
|
+
"""Active cooldowns only: alias -> {until, why}."""
|
|
167
|
+
now = dt.datetime.now().astimezone()
|
|
168
|
+
out = {}
|
|
169
|
+
for alias, c in _read(COOLDOWNS).items():
|
|
170
|
+
try:
|
|
171
|
+
if dt.datetime.fromisoformat(c["until"]) > now:
|
|
172
|
+
out[alias] = c
|
|
173
|
+
except (KeyError, ValueError, TypeError):
|
|
174
|
+
continue
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def note_capability(alias: str, cap: str, evidence: str) -> None:
|
|
179
|
+
"""A capability SEEN working (a tool call returned, reasoning tokens
|
|
180
|
+
present). Only positives are stored: a probe that fails proves nothing."""
|
|
181
|
+
seen = {"value": True, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
182
|
+
"evidence": evidence[:200]}
|
|
183
|
+
locked.update(LEARNED, lambda data: data.setdefault(alias, {}).setdefault("capabilities", {})
|
|
184
|
+
.__setitem__(cap, seen), ensure_ascii=False)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def note_served_as(alias: str, requested: str, served: str) -> None:
|
|
188
|
+
"""The response named a different model than the one asked for."""
|
|
189
|
+
swap = {"requested": requested, "served": served,
|
|
190
|
+
"at": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
|
|
191
|
+
locked.update(LEARNED, lambda data: data.setdefault(alias, {}).__setitem__("served_as", swap),
|
|
192
|
+
ensure_ascii=False)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _selftest() -> None:
|
|
196
|
+
p = parse("Error code: 400 - This model's maximum context length is 131,072 tokens. However,"
|
|
197
|
+
" you requested 140000 tokens")
|
|
198
|
+
assert p["context_window"] == 131072 and not p["rate_limited"], p
|
|
199
|
+
p = parse("429 Too Many Requests: Rate limit reached for model in organization: Limit 30,"
|
|
200
|
+
" Used 30, Requested 1. Please try again in 2.5s. (requests per minute)")
|
|
201
|
+
assert p["limits"] == {"rpm": 30} and p["retry_after_s"] == 2.5 and p["rate_limited"], p
|
|
202
|
+
p = parse('{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"retryDelay":"17s"}]}}')
|
|
203
|
+
assert p["retry_after_s"] == 17 and p["rate_limited"], p
|
|
204
|
+
p = parse("Rate limit exceeded: free-models-per-day. Try again in 3 minutes")
|
|
205
|
+
assert p["retry_after_s"] == 180 and "limits" not in p, p # no number under a named axis
|
|
206
|
+
# Two axes named: the number is not filed under either.
|
|
207
|
+
assert "limits" not in parse("limit 100 (tokens per minute) and requests per minute")
|
|
208
|
+
assert parse("model not found") == {"rate_limited": False}
|
|
209
|
+
assert parse("context length of 8k exceeded").get("context_window") is None # "8" < 512
|
|
210
|
+
print("ok")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main() -> None:
|
|
214
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
215
|
+
if "--selftest" in sys.argv:
|
|
216
|
+
_selftest()
|
|
217
|
+
return
|
|
218
|
+
for alias, e in sorted(learned().items()):
|
|
219
|
+
bits = []
|
|
220
|
+
if e.get("context_window"):
|
|
221
|
+
bits.append(f"context {e['context_window']['value']:,}")
|
|
222
|
+
bits += [f"{k} {v['value']}" for k, v in (e.get("limits") or {}).items()]
|
|
223
|
+
bits += [f"can {k}" for k in (e.get("capabilities") or {})]
|
|
224
|
+
if e.get("served_as"):
|
|
225
|
+
bits.append(f"served as {e['served_as']['served']}")
|
|
226
|
+
print(f" {alias:<34} {', '.join(bits)}")
|
|
227
|
+
for alias, c in sorted(cooldowns().items()):
|
|
228
|
+
print(f" cooling {alias:<26} until {c['until'][11:16]} ({c['why'][:70]})")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
233
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
234
|
+
main()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The usage ledger: one JSON line for every call the built-in client makes.
|
|
2
|
+
|
|
3
|
+
python metrics/ledger.py # calls and tokens per connection: last hour, last day, this month
|
|
4
|
+
|
|
5
|
+
RELEASE-PLAN Phase 2b, "evidence by default". A line:
|
|
6
|
+
|
|
7
|
+
{"t": 1761264000.123, "at": "2026-10-23T19:00:00-05:00", "alias": "...",
|
|
8
|
+
"connection": "groq", "endpoint": "openwebui", "served": "llama-...",
|
|
9
|
+
"status": "ok", "attempts": 1, "tokens_in": 812, "tokens_out": 96,
|
|
10
|
+
"reasoning": 0, "seconds": 2.4, "caller": "gateway", "purpose": ""}
|
|
11
|
+
|
|
12
|
+
`t` is when the call STARTED (epoch seconds): that is the moment a provider's
|
|
13
|
+
per-minute window counts, and pacing counts the same way. `at` is the same
|
|
14
|
+
moment for people. `attempts` is how many HTTP requests the call sent,
|
|
15
|
+
retries included, and 0 for a call the bench refused to send, since requests
|
|
16
|
+
are what a provider's request limits count. `status` is ok, empty, error,
|
|
17
|
+
timeout or refused (not sent: pacing, or the free-tier guard). Tokens are the
|
|
18
|
+
provider's own figures from the reply's usage, absent when it gave none.
|
|
19
|
+
`caller` is the script that made the call (gateway, perf/run, suite/run, ...)
|
|
20
|
+
and `purpose` what the caller said it was for, when it said.
|
|
21
|
+
|
|
22
|
+
A FILE A MONTH
|
|
23
|
+
--------------
|
|
24
|
+
metrics/usage/YYYY-MM.jsonl, by UTC month. Pacing (metrics/pacing.py) counts
|
|
25
|
+
a window before every call on a limited connection, and its longest window
|
|
26
|
+
is a calendar month, so a count reads this month's file and at most the one
|
|
27
|
+
before, never the whole history.
|
|
28
|
+
|
|
29
|
+
Lines are appended under locked.lock, so the gateway's seat threads and the
|
|
30
|
+
suite's processes never interleave half-lines. A reader takes no lock and
|
|
31
|
+
skips a line it cannot parse (one still being written).
|
|
32
|
+
|
|
33
|
+
It replaces metrics/cost.py's read of the llm CLI's logs.db. Calls made
|
|
34
|
+
through the llm backend are still only in logs.db, so cost.py reads both;
|
|
35
|
+
nothing is in both.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import calendar
|
|
41
|
+
import collections
|
|
42
|
+
import datetime as dt
|
|
43
|
+
import json
|
|
44
|
+
import pathlib
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
|
|
48
|
+
import locked
|
|
49
|
+
|
|
50
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
51
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
52
|
+
DIR = DATA / "metrics" / "usage"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _file(t: float) -> pathlib.Path:
|
|
56
|
+
return DIR / (time.strftime("%Y-%m", time.gmtime(t)) + ".jsonl")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def append(row: dict) -> None:
|
|
60
|
+
"""Add one call's line. `row["t"]` (its start) picks the month's file."""
|
|
61
|
+
line = json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
62
|
+
path = _file(row["t"])
|
|
63
|
+
with locked.lock(DIR / "usage"):
|
|
64
|
+
with open(path, "a", encoding="utf-8", newline="\n") as fh:
|
|
65
|
+
fh.write(line)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def month_start(t: float) -> float:
|
|
69
|
+
"""The first moment of t's calendar month, UTC."""
|
|
70
|
+
g = time.gmtime(t)
|
|
71
|
+
return float(calendar.timegm((g.tm_year, g.tm_mon, 1, 0, 0, 0)))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def next_month(t: float) -> float:
|
|
75
|
+
"""The first moment of the calendar month after t's, UTC."""
|
|
76
|
+
g = time.gmtime(t)
|
|
77
|
+
y, m = (g.tm_year + 1, 1) if g.tm_mon == 12 else (g.tm_year, g.tm_mon + 1)
|
|
78
|
+
return float(calendar.timegm((y, m, 1, 0, 0, 0)))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def rows(since: float = 0.0, until: float | None = None):
|
|
82
|
+
"""Every line that started at or after `since` (and before `until`), oldest
|
|
83
|
+
file first. since=0 reads every month there is."""
|
|
84
|
+
if since <= 0:
|
|
85
|
+
files = sorted(DIR.glob("*.jsonl"))
|
|
86
|
+
else:
|
|
87
|
+
files, t = [], month_start(since)
|
|
88
|
+
end = until or time.time()
|
|
89
|
+
while t <= end:
|
|
90
|
+
files.append(_file(t))
|
|
91
|
+
t = next_month(t)
|
|
92
|
+
for path in files:
|
|
93
|
+
try:
|
|
94
|
+
fh = open(path, encoding="utf-8", errors="replace")
|
|
95
|
+
except OSError:
|
|
96
|
+
continue
|
|
97
|
+
with fh:
|
|
98
|
+
for line in fh:
|
|
99
|
+
try:
|
|
100
|
+
row = json.loads(line)
|
|
101
|
+
except ValueError:
|
|
102
|
+
continue # a line still being written
|
|
103
|
+
if not isinstance(row, dict) or not isinstance(row.get("t"), (int, float)):
|
|
104
|
+
continue
|
|
105
|
+
if row["t"] >= since and (until is None or row["t"] < until):
|
|
106
|
+
yield row
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def tokens(usage: dict) -> dict:
|
|
110
|
+
"""{tokens_in, tokens_out, reasoning} from a reply's usage, in either
|
|
111
|
+
spelling: chat completions' prompt/completion tokens, or the Responses
|
|
112
|
+
API's input/output tokens. Absent keys were not reported."""
|
|
113
|
+
if not isinstance(usage, dict):
|
|
114
|
+
return {}
|
|
115
|
+
out = {}
|
|
116
|
+
for key, names in (("tokens_in", ("prompt_tokens", "input_tokens")),
|
|
117
|
+
("tokens_out", ("completion_tokens", "output_tokens"))):
|
|
118
|
+
val = next((usage[n] for n in names if isinstance(usage.get(n), int)), None)
|
|
119
|
+
if val is not None:
|
|
120
|
+
out[key] = val
|
|
121
|
+
details = usage.get("completion_tokens_details") or usage.get("output_tokens_details") or {}
|
|
122
|
+
if isinstance(details, dict) and isinstance(details.get("reasoning_tokens"), int):
|
|
123
|
+
out["reasoning"] = details["reasoning_tokens"]
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
129
|
+
now = time.time()
|
|
130
|
+
spans = (("hour", now - 3600), ("day", now - 86400), ("month", month_start(now)))
|
|
131
|
+
agg: dict = collections.defaultdict(lambda: {s: [0, 0] for s, _ in spans})
|
|
132
|
+
for row in rows(min(t for _, t in spans)):
|
|
133
|
+
for span, since in spans:
|
|
134
|
+
if row["t"] >= since:
|
|
135
|
+
a = agg[row.get("connection") or "?"][span]
|
|
136
|
+
a[0] += row.get("attempts") or 0
|
|
137
|
+
a[1] += (row.get("tokens_in") or 0) + (row.get("tokens_out") or 0)
|
|
138
|
+
if not agg:
|
|
139
|
+
print(f"no calls in the ledger this month ({DIR})")
|
|
140
|
+
return
|
|
141
|
+
print(f"{'connection':<24}" + "".join(f"{s + ' (req/tok)':>22}" for s, _ in spans))
|
|
142
|
+
for conn, a in sorted(agg.items()):
|
|
143
|
+
print(f"{conn:<24}" + "".join(f"{a[s][0]:>10,} / {a[s][1]:>9,}" for s, _ in spans))
|
|
144
|
+
print(f"\nmonth from {dt.datetime.fromtimestamp(month_start(now), dt.timezone.utc):%Y-%m-%d} UTC; {DIR}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|