@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.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -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 +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -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/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. 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 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 _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()