@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.0
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 +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +170 -44
- package/ui/bench.js +6 -3
package/metrics/guard.py
CHANGED
|
@@ -31,8 +31,10 @@ Decided by ROUTE, never by what a request happens to carry, so an API call
|
|
|
31
31
|
with a bearer never meets the login cookie and a browser's cookie never opens
|
|
32
32
|
the gateway:
|
|
33
33
|
|
|
34
|
-
- the gateway's routes (`/v1/...`, `/
|
|
35
|
-
GATEWAY KEY as `Authorization:
|
|
34
|
+
- the gateway's routes (`/v1/...`, `/v1beta/...`, `/bench/...` and Ollama's
|
|
35
|
+
five `/api/` names, on either port) take a GATEWAY KEY as `Authorization:
|
|
36
|
+
Bearer <key>` (or `x-api-key`, `x-goog-api-key`, a Gemini route's `?key=`,
|
|
37
|
+
as those SDKs send it): named, revocable, kept only
|
|
36
38
|
as a SHA-256 in `config/gateway-keys.json`, shown once when made. A key
|
|
37
39
|
never opens the dashboard, so a chat UI or agent given one cannot change
|
|
38
40
|
settings, read keys or spend through it.
|
|
@@ -81,7 +83,15 @@ PREFIX = "bwk_" # every gateway key starts so: a secret scann
|
|
|
81
83
|
COOKIE = "bw_session"
|
|
82
84
|
SESSION_S = 7 * 24 * 3600
|
|
83
85
|
MIN_TOKEN = 16
|
|
84
|
-
GATEWAY_ROUTES = ("/v1/", "/bench/")
|
|
86
|
+
GATEWAY_ROUTES = ("/v1/", "/v1beta/", "/bench/")
|
|
87
|
+
# Ollama's routes, which the gateway answers too (metrics/formats.py). They sit
|
|
88
|
+
# under the dashboard's /api/, so these five names are the gateway's, on either
|
|
89
|
+
# port, and the dashboard must never use them.
|
|
90
|
+
OLLAMA_ROUTES = ("/api/chat", "/api/generate", "/api/tags", "/api/version", "/api/show")
|
|
91
|
+
# Where a client may send its key other than `Authorization: Bearer`: the
|
|
92
|
+
# Anthropic SDK's header, and Google's (its SDKs send the header; REST
|
|
93
|
+
# examples put the key in ?key=, which is taken on Gemini routes only).
|
|
94
|
+
KEY_HEADERS = ("x-api-key", "x-goog-api-key")
|
|
85
95
|
|
|
86
96
|
|
|
87
97
|
class Refused(Exception):
|
|
@@ -210,7 +220,7 @@ def startup(address: str) -> None:
|
|
|
210
220
|
|
|
211
221
|
def gateway_route(path: str) -> bool:
|
|
212
222
|
"""A route the gateway answers, on its own port or the dashboard's."""
|
|
213
|
-
return path.startswith(GATEWAY_ROUTES)
|
|
223
|
+
return path.startswith(GATEWAY_ROUTES) or path.rstrip("/") in OLLAMA_ROUTES
|
|
214
224
|
|
|
215
225
|
|
|
216
226
|
def _sign(expires: int) -> bytes:
|
|
@@ -267,17 +277,24 @@ def admin_caller(headers) -> str:
|
|
|
267
277
|
return "admin"
|
|
268
278
|
|
|
269
279
|
|
|
270
|
-
def gateway_caller(headers) -> str:
|
|
280
|
+
def gateway_caller(headers, query_key: str | None = None) -> str:
|
|
271
281
|
"""Who is asking for a gateway route: the gateway key's name, or "local"
|
|
272
282
|
when keys are not required (any bearer is ignored then: SDKs send a dummy
|
|
273
283
|
one). Raises Refused(401), saying which of missing, malformed or unknown
|
|
274
|
-
it was. The cookie is never looked at.
|
|
284
|
+
it was. The cookie is never looked at.
|
|
285
|
+
|
|
286
|
+
The key is the first of `Authorization: Bearer`, `x-api-key`,
|
|
287
|
+
`x-goog-api-key` and `query_key` (a Gemini route's ?key=) that is sent:
|
|
288
|
+
one credential is judged, never a second tried after a bad first."""
|
|
275
289
|
if not keys_required():
|
|
276
290
|
return "local"
|
|
277
291
|
challenge = {"WWW-Authenticate": 'Bearer realm="benchwarmers"'} # RFC 6750: every 401 says how
|
|
278
292
|
raw = headers.get("Authorization")
|
|
293
|
+
other = next((headers.get(h) for h in KEY_HEADERS if headers.get(h) is not None), query_key)
|
|
294
|
+
if raw is None and other is not None:
|
|
295
|
+
raw = f"Bearer {other}"
|
|
279
296
|
if raw is None:
|
|
280
|
-
raise Refused(401, "this route needs a gateway key (Authorization: Bearer <key
|
|
297
|
+
raise Refused(401, "this route needs a gateway key (Authorization: Bearer <key>, or x-api-key);"
|
|
281
298
|
" make one on the dashboard's Settings page", challenge)
|
|
282
299
|
scheme, _, value = raw.strip().partition(" ")
|
|
283
300
|
value = value.strip()
|
|
@@ -540,6 +557,17 @@ def _selftest() -> None:
|
|
|
540
557
|
assert key_name("") is None and key_name(key + "x") is None and key_name("bwk_é") is None
|
|
541
558
|
assert gateway_caller({"Authorization": f"Bearer {key}"}) == "chat-ui"
|
|
542
559
|
assert gateway_caller({"Authorization": f"bearer {key} "}) == "chat-ui"
|
|
560
|
+
# Other APIs' places for a key: Anthropic's header, Google's header and ?key=.
|
|
561
|
+
assert gateway_caller({"x-api-key": key}) == "chat-ui"
|
|
562
|
+
assert gateway_caller({"x-goog-api-key": key}) == "chat-ui"
|
|
563
|
+
assert gateway_caller({}, query_key=key) == "chat-ui"
|
|
564
|
+
for headers, q in (({"Authorization": "Bearer sk-dummy", "x-api-key": key}, None), # the first sent is judged
|
|
565
|
+
({"x-api-key": "nope"}, key), ({"x-api-key": ""}, None), ({}, "")):
|
|
566
|
+
try:
|
|
567
|
+
gateway_caller(headers, q)
|
|
568
|
+
raise AssertionError(f"gateway let in {headers} {q!r}")
|
|
569
|
+
except Refused as r:
|
|
570
|
+
assert r.status == 401, r
|
|
543
571
|
entry = gateway_keys()["chat-ui"]
|
|
544
572
|
assert entry["hint"] == key[-4:] and entry["last_used"] and "rpm" not in entry, entry
|
|
545
573
|
# Each refusal says which it was, and how to authenticate (RFC 6750).
|
|
@@ -605,6 +633,8 @@ def _selftest() -> None:
|
|
|
605
633
|
KEYS = real
|
|
606
634
|
assert gateway_route("/v1/models") and gateway_route("/bench/verdict")
|
|
607
635
|
assert not gateway_route("/api/endpoints") and not gateway_route("/v1") and not gateway_route("//v1/x")
|
|
636
|
+
assert gateway_route("/api/tags") and gateway_route("/api/chat/") and gateway_route("/v1beta/models")
|
|
637
|
+
assert not gateway_route("/api/tagsx") and not gateway_route("/api/stamp")
|
|
608
638
|
finally:
|
|
609
639
|
for k, v in saved.items():
|
|
610
640
|
os.environ.pop(k, None)
|
package/metrics/learn.py
CHANGED
|
@@ -114,12 +114,8 @@ def _known(alias: str, field: str, axis: str | None = None):
|
|
|
114
114
|
return min(vals) if vals else None
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def
|
|
118
|
-
"""
|
|
119
|
-
found = parse(text)
|
|
120
|
-
now = dt.datetime.now().astimezone()
|
|
121
|
-
stamp = now.isoformat(timespec="seconds")
|
|
122
|
-
evidence = " ".join((text or "").split())[:200]
|
|
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."""
|
|
123
119
|
stored: dict = {}
|
|
124
120
|
|
|
125
121
|
def tighten(data: dict) -> None:
|
|
@@ -140,6 +136,23 @@ def record(alias: str, text: str, where: str) -> dict:
|
|
|
140
136
|
|
|
141
137
|
if alias and (found.get("context_window") or found.get("limits")):
|
|
142
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)
|
|
143
156
|
if alias and found.get("rate_limited"):
|
|
144
157
|
wait = found.get("retry_after_s") or DEFAULT_COOLDOWN_S
|
|
145
158
|
cool = {"until": (now + dt.timedelta(seconds=wait)).isoformat(timespec="seconds"),
|
|
@@ -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()
|
package/metrics/model_roles.py
CHANGED
|
@@ -16,6 +16,11 @@ roles: general reasoning / do-it-all: eligible for every shape
|
|
|
16
16
|
bounded tasks (classify, extract, summarise, enumerate over a
|
|
17
17
|
small file); not review or design over a large packet
|
|
18
18
|
image image generation; never a reviewer
|
|
19
|
+
vision a specialist at reading images (OCR, documents, screenshots,
|
|
20
|
+
charts), weak at text-only work: admitted by the vision shape
|
|
21
|
+
alone, and a collection member with this role sits only when
|
|
22
|
+
the request carries images (gateway.py). A model that reads
|
|
23
|
+
images AND does everything else stays general.
|
|
19
24
|
decision typed answers with probabilities through a Decisions API, not
|
|
20
25
|
chat; no shape admits them (config/decision-models.json)
|
|
21
26
|
|
|
@@ -30,8 +35,11 @@ shapes: today's task, `route.py --shape <name>`; general when none is given.
|
|
|
30
35
|
ad_hoc: aliases NEVER chosen automatically: not by the router's seats or
|
|
31
36
|
reserve, not by a benchmark sweep or `--next` rotation, not by the
|
|
32
37
|
daily perf run. Orthogonal to role: a role says what a model is FOR,
|
|
33
|
-
this says what it COSTS. They answer only when named
|
|
34
|
-
`
|
|
38
|
+
this says what it COSTS. They answer only when named: `--with` or
|
|
39
|
+
the gateway's `with`, a collection that lists them as a member or
|
|
40
|
+
merger (every request through it spends their quota), `llm -m`, a
|
|
41
|
+
benchmark's `--model`. `bench onboard --quick --ad-hoc` (or Quick + ad
|
|
42
|
+
hoc in the picker) onboards one this way from the start.
|
|
35
43
|
|
|
36
44
|
A role never lowers a score: rank is decided by evidence when seats are
|
|
37
45
|
picked; a role only decides which shapes a model is eligible for.
|
|
@@ -51,6 +59,8 @@ ROLES = {
|
|
|
51
59
|
"light": "small, fast instruct model — eligible for quick and enumeration;"
|
|
52
60
|
" not for review or design over a large packet",
|
|
53
61
|
"image": "image generation — never a reviewer",
|
|
62
|
+
"vision": "reads images (OCR, documents, screenshots) — sits only for requests that carry"
|
|
63
|
+
" images; never a text reviewer",
|
|
54
64
|
"decision": "typed choice / score / yes-no with probabilities, through a Decisions API"
|
|
55
65
|
" — no chat, never a seat; see config/decision-models.json",
|
|
56
66
|
}
|
|
@@ -62,7 +72,7 @@ SHAPES = {
|
|
|
62
72
|
"code": ["general", "code"],
|
|
63
73
|
"enumeration": ["general", "code", "light"],
|
|
64
74
|
"quick": ["general", "light"],
|
|
65
|
-
"vision": ["general", "light"],
|
|
75
|
+
"vision": ["general", "light", "vision"],
|
|
66
76
|
"image": ["image"],
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Pacing: the stored rate limits enforced before a call, not only shown.
|
|
2
|
+
|
|
3
|
+
python metrics/pacing.py <alias> # the limits that apply, and how much of each is used
|
|
4
|
+
|
|
5
|
+
RELEASE-PLAN Phase 2b ("limits enforced, not just shown"). Until now the
|
|
6
|
+
limits in config/connection-limits.json (the Endpoints dialog), a model's own
|
|
7
|
+
`limits` in config/model-overrides.json (its popup) and the limits
|
|
8
|
+
metrics/learn.py read out of providers' errors were display only. The
|
|
9
|
+
built-in client now asks `admit` before every call. When a window is used
|
|
10
|
+
up it waits, if the wait ends before the call's deadline, and otherwise
|
|
11
|
+
refuses with an error that names the limit, so nothing is sent that the
|
|
12
|
+
provider would refuse, and nothing is spent of a cap that does not come back
|
|
13
|
+
(the motivating case: a trial key at 20 a minute and 1,000 a month).
|
|
14
|
+
|
|
15
|
+
WHAT IS COUNTED
|
|
16
|
+
---------------
|
|
17
|
+
- rpm, tpm: the last 60 s. rpd, tpd: the last 24 h, which never allows more
|
|
18
|
+
than a provider's calendar day would, however it counts its day.
|
|
19
|
+
rpmo (connection limits only): the calendar month in UTC, how trial keys
|
|
20
|
+
are reset.
|
|
21
|
+
- Requests are the ledger's `attempts` (metrics/ledger.py), retries included:
|
|
22
|
+
what the provider saw. Tokens are its in plus out.
|
|
23
|
+
- Calls in flight count too. A panel fires its seats at once, long before
|
|
24
|
+
any of them is in the ledger, so each admitted call is written to
|
|
25
|
+
runs/inflight.json until its ledger line exists. The check and the write
|
|
26
|
+
happen under one lock (runs/pacing.lock), which the gateway's seat threads
|
|
27
|
+
and the suite's processes share; an entry whose call outlived its own
|
|
28
|
+
deadline is dropped, so a killed process cannot hold a slot.
|
|
29
|
+
- A connection's limits count every model on it (a key's limits are the
|
|
30
|
+
key's); a model's own or learned limits count that model alone, its own
|
|
31
|
+
beating the learned (as everywhere: your values win).
|
|
32
|
+
- Quota records (metrics/quota.py): what a provider last said was left, less
|
|
33
|
+
the calls sent since, until its reset.
|
|
34
|
+
|
|
35
|
+
The catalog refresh's figures (FreeLLMAPI's listing) are not enforced: they
|
|
36
|
+
describe a typical free tier, not this key's.
|
|
37
|
+
|
|
38
|
+
THE REFUSAL IS A RATE LIMIT
|
|
39
|
+
---------------------------
|
|
40
|
+
`Error: client failed: rate limit (not sent; ...): ...; try again in 41s`.
|
|
41
|
+
"client failed" and "rate limit" are markers (suite/run.py ERRORS and
|
|
42
|
+
CAPACITY), so it counts as a retryable capacity failure, never as the model
|
|
43
|
+
answering badly or going away; and learn.py reads "try again in 41s" as the
|
|
44
|
+
cooldown, so the gateway's explorer seats pass the model by meanwhile. The
|
|
45
|
+
text names no "limit N" figure, which learn.py would file as a new limit.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import datetime as dt
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import pathlib
|
|
54
|
+
import sys
|
|
55
|
+
import time
|
|
56
|
+
import uuid
|
|
57
|
+
|
|
58
|
+
import ledger
|
|
59
|
+
import locked
|
|
60
|
+
|
|
61
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
62
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
63
|
+
LOCK = DATA / "runs" / "pacing"
|
|
64
|
+
INFLIGHT = DATA / "runs" / "inflight.json"
|
|
65
|
+
# axis -> (what it counts, the window in seconds or "month", how it reads)
|
|
66
|
+
AXES = {"rpm": ("requests", 60, "requests per minute"), "rpd": ("requests", 86400, "requests per day"),
|
|
67
|
+
"tpm": ("tokens", 60, "tokens per minute"), "tpd": ("tokens", 86400, "tokens per day"),
|
|
68
|
+
"rpmo": ("requests", "month", "requests a calendar month (UTC)")}
|
|
69
|
+
MARGIN_S = 2.0 # a wait must leave this long for the call itself
|
|
70
|
+
POLL_S = 5.0 # re-check at least this often while waiting
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def rules(alias: str, connection: str) -> list[dict]:
|
|
74
|
+
"""The limits that apply: {scope, axis, limit, source}."""
|
|
75
|
+
import catalog
|
|
76
|
+
import learn
|
|
77
|
+
conn = catalog.connection_limits().get(connection) or {}
|
|
78
|
+
mine = (catalog.overrides().get(alias) or {}).get("limits") or {}
|
|
79
|
+
seen = (learn.learned().get(alias) or {}).get("limits") or {}
|
|
80
|
+
out = []
|
|
81
|
+
|
|
82
|
+
def ok(n) -> bool:
|
|
83
|
+
return isinstance(n, int) and not isinstance(n, bool) and n > 0
|
|
84
|
+
for axis in AXES:
|
|
85
|
+
if ok(n := conn.get(axis)):
|
|
86
|
+
out.append({"scope": "connection", "axis": axis, "limit": n,
|
|
87
|
+
"source": f"you, for {connection} (config/connection-limits.json)"})
|
|
88
|
+
if axis == "rpmo":
|
|
89
|
+
continue
|
|
90
|
+
if ok(n := mine.get(axis)):
|
|
91
|
+
out.append({"scope": "alias", "axis": axis, "limit": n,
|
|
92
|
+
"source": "you, for this model (config/model-overrides.json)"})
|
|
93
|
+
elif ok(n := (seen.get(axis) or {}).get("value")):
|
|
94
|
+
out.append({"scope": "alias", "axis": axis, "limit": n,
|
|
95
|
+
"source": f"learned {str(seen[axis].get('at', ''))[:10]} from the provider"})
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _wait_text(s: float) -> str:
|
|
100
|
+
"""learn.py's spelling (s, m or h), so it reads the cooldown back."""
|
|
101
|
+
return f"{s:.0f}s" if s < 120 else f"{s / 60:.0f}m" if s < 7200 else f"{s / 3600:.0f}h"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _inflight(now: float) -> dict:
|
|
105
|
+
return {k: v for k, v in locked.read_json(INFLIGHT).items()
|
|
106
|
+
if isinstance(v, dict) and (v.get("until") or 0) > now}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _blocked(rs: list[dict], records: list[dict], alias: str, connection: str, est: int,
|
|
110
|
+
now: float, rows: list[dict], flying: dict) -> tuple[float, str] | None:
|
|
111
|
+
"""(when a slot frees, why) for the window that frees last, or None when
|
|
112
|
+
every window has room for this call."""
|
|
113
|
+
worst = None
|
|
114
|
+
|
|
115
|
+
def mine(e: dict, scope: str) -> bool:
|
|
116
|
+
return e.get("connection") == connection if scope == "connection" else e.get("alias") == alias
|
|
117
|
+
for r in rs:
|
|
118
|
+
what, span, reads = AXES[r["axis"]]
|
|
119
|
+
since = ledger.month_start(now) if span == "month" else now - span
|
|
120
|
+
events = sorted(
|
|
121
|
+
[(e["t"], (e.get("attempts") or 0) if what == "requests"
|
|
122
|
+
else (e.get("tokens_in") or 0) + (e.get("tokens_out") or 0))
|
|
123
|
+
for e in rows if e["t"] >= since and mine(e, r["scope"])]
|
|
124
|
+
+ [(f["t"], 1 if what == "requests" else f.get("tokens") or 0)
|
|
125
|
+
for f in flying.values() if mine(f, r["scope"])])
|
|
126
|
+
used = sum(w for _, w in events)
|
|
127
|
+
# A prompt larger than the whole window waits for an empty one, then goes:
|
|
128
|
+
# the estimate is rough, and the provider has the last word.
|
|
129
|
+
need = 1 if what == "requests" else min(max(est, 1), r["limit"])
|
|
130
|
+
if used + need <= r["limit"]:
|
|
131
|
+
continue
|
|
132
|
+
if span == "month":
|
|
133
|
+
frees = ledger.next_month(now)
|
|
134
|
+
else:
|
|
135
|
+
frees, gone = now, 0
|
|
136
|
+
for t, w in events: # oldest first, until enough have aged out
|
|
137
|
+
gone += w
|
|
138
|
+
frees = t + span
|
|
139
|
+
if used - gone + need <= r["limit"]:
|
|
140
|
+
break
|
|
141
|
+
whose = connection if r["scope"] == "connection" else alias
|
|
142
|
+
why = (f"{whose} allows {r['limit']:,} {reads} ({r['source']}) and {used:,} "
|
|
143
|
+
f"{'were' if what == 'requests' else 'tokens were'} used"
|
|
144
|
+
f"{' this month' if span == 'month' else ''}")
|
|
145
|
+
if worst is None or frees > worst[0]:
|
|
146
|
+
worst = (frees, why)
|
|
147
|
+
for q in records:
|
|
148
|
+
what = "tokens" if "tokens" in q.get("metric", "") else "requests"
|
|
149
|
+
if not isinstance(q.get("remaining"), int):
|
|
150
|
+
continue
|
|
151
|
+
try:
|
|
152
|
+
seen = _ts(q["observed_at"])
|
|
153
|
+
reset = _ts(q["reset_at"])
|
|
154
|
+
except (KeyError, TypeError, ValueError):
|
|
155
|
+
continue
|
|
156
|
+
used = sum((e.get("attempts") or 0) if what == "requests"
|
|
157
|
+
else (e.get("tokens_in") or 0) + (e.get("tokens_out") or 0)
|
|
158
|
+
for e in rows if e["t"] >= seen and e.get("alias") == alias)
|
|
159
|
+
used += sum(1 if what == "requests" else f.get("tokens") or 0
|
|
160
|
+
for f in flying.values() if f.get("alias") == alias)
|
|
161
|
+
if q["remaining"] - used > 0:
|
|
162
|
+
continue
|
|
163
|
+
limit = f" of {q['limit']:,}" if isinstance(q.get("limit"), int) else ""
|
|
164
|
+
window = "" if q.get("window") in ("unknown", "retry-after") else f" a {q['window']}"
|
|
165
|
+
why = (f"{connection} said {max(q['remaining'], 0):,}{limit} {q['metric']}{window} were left for {alias}"
|
|
166
|
+
f" ({q.get('source')}, {_ago(now - seen)})")
|
|
167
|
+
if worst is None or reset > worst[0]:
|
|
168
|
+
worst = (reset, why)
|
|
169
|
+
return worst
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _ts(iso: str) -> float:
|
|
173
|
+
return dt.datetime.fromisoformat(iso).timestamp()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ago(s: float) -> str:
|
|
177
|
+
return "just now" if s < 60 else f"{s / 60:.0f} min ago" if s < 7200 else f"{s / 3600:.0f} h ago"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def admit(alias: str, connection: str, est_tokens: int, left: float, sleep=time.sleep) -> tuple[str | None, str]:
|
|
181
|
+
"""Wait for room, or refuse. Returns (ticket, refusal): a ticket to pass
|
|
182
|
+
to `release` once the call's ledger line is written (None when no limit
|
|
183
|
+
applies, so there is nothing to release), or the refusal text."""
|
|
184
|
+
import quota
|
|
185
|
+
rs = rules(alias, connection)
|
|
186
|
+
end = time.time() + left # wall clock: the in-flight entry's expiry, read by other processes
|
|
187
|
+
stop = time.monotonic() + left # the caller's deadline, which a clock change cannot move
|
|
188
|
+
while True:
|
|
189
|
+
records = quota.live(connection, alias)
|
|
190
|
+
if not rs and not records:
|
|
191
|
+
return None, ""
|
|
192
|
+
spans = [AXES[r["axis"]][1] for r in rs]
|
|
193
|
+
since = min([ledger.month_start(time.time()) if s == "month" else time.time() - s for s in spans]
|
|
194
|
+
+ [_ts(q["observed_at"]) for q in records if q.get("observed_at")])
|
|
195
|
+
with locked.lock(LOCK):
|
|
196
|
+
now = time.time()
|
|
197
|
+
rows = list(ledger.rows(since))
|
|
198
|
+
flying = _inflight(now)
|
|
199
|
+
block = _blocked(rs, records, alias, connection, est_tokens, now, rows, flying)
|
|
200
|
+
if block is None:
|
|
201
|
+
ticket = uuid.uuid4().hex
|
|
202
|
+
flying[ticket] = {"alias": alias, "connection": connection, "t": now, "tokens": est_tokens,
|
|
203
|
+
"until": end + 60, "pid": os.getpid()}
|
|
204
|
+
locked.write_atomic(INFLIGHT, json.dumps(flying, indent=1) + "\n")
|
|
205
|
+
return ticket, ""
|
|
206
|
+
frees, why = block
|
|
207
|
+
wait = max(frees - time.time(), 0.05)
|
|
208
|
+
if time.monotonic() + wait > stop - MARGIN_S:
|
|
209
|
+
return None, (f"Error: client failed: rate limit (not sent; paced by the bench): {why};"
|
|
210
|
+
f" try again in {_wait_text(wait)}")
|
|
211
|
+
sleep(min(wait, POLL_S))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def release(ticket: str | None) -> None:
|
|
215
|
+
"""The call's ledger line is written: it no longer counts as in flight."""
|
|
216
|
+
if not ticket:
|
|
217
|
+
return
|
|
218
|
+
with locked.lock(LOCK):
|
|
219
|
+
flying = _inflight(time.time()) # stale entries go too
|
|
220
|
+
flying.pop(ticket, None)
|
|
221
|
+
locked.write_atomic(INFLIGHT, json.dumps(flying, indent=1) + "\n")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main() -> None:
|
|
225
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
226
|
+
if len(sys.argv) != 2:
|
|
227
|
+
print(__doc__.split("\n\n")[0])
|
|
228
|
+
return
|
|
229
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
230
|
+
import route
|
|
231
|
+
alias = sys.argv[1]
|
|
232
|
+
conn = route.connection_of(alias)
|
|
233
|
+
now = time.time()
|
|
234
|
+
rows = list(ledger.rows(ledger.month_start(now) - 86400))
|
|
235
|
+
rs = rules(alias, conn)
|
|
236
|
+
if not rs:
|
|
237
|
+
print(f"{alias} ({conn}): no limit is set or learned, so nothing paces it")
|
|
238
|
+
for r in rs:
|
|
239
|
+
what, span, reads = AXES[r["axis"]]
|
|
240
|
+
since = ledger.month_start(now) if span == "month" else now - span
|
|
241
|
+
used = sum((e.get("attempts") or 0) if what == "requests" else
|
|
242
|
+
(e.get("tokens_in") or 0) + (e.get("tokens_out") or 0) for e in rows if e["t"] >= since and
|
|
243
|
+
(e.get("connection") == conn if r["scope"] == "connection" else e.get("alias") == alias))
|
|
244
|
+
print(f" {r['axis']:<5} {used:>9,} of {r['limit']:<9,} {reads} ({r['source']})")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|