@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/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/...`, `/bench/...`, on either port) take a
35
- GATEWAY KEY as `Authorization: Bearer <key>`: named, revocable, kept only
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 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]
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()
@@ -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 (`--with`,
34
- `llm -m`, a benchmark's `--model`).
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()