@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.
@@ -0,0 +1,188 @@
1
+ """The built-in client on native Anthropic Messages and Gemini APIs, against fake servers.
2
+
3
+ python scripts/test_native.py
4
+
5
+ An endpoint whose `api` is `anthropic` or `gemini`: the request is built
6
+ by metrics/formats.py (system text apart, the model's own max_tokens, an
7
+ image as the API's own part), sent with the API's key header and streamed;
8
+ the reply is read into the client's usual result (text, reasoning, usage,
9
+ served model, finish) and its ledger line; an HTTP error and an error sent
10
+ mid-stream keep the error-text contract (`Error: Error code: 429 - ...`,
11
+ 529 for Anthropic's overloaded_error), a stream that breaks off is
12
+ `client failed`. Each endpoint's model list in its own shape, the
13
+ dashboard's Add form saving the choice, and such a model always going
14
+ through the built-in client. Temp data folder; no model is called.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import pathlib
22
+ import shutil
23
+ import sys
24
+ import tempfile
25
+ import threading
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+
28
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
29
+ TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-native-"))
30
+ (TMP / "config").mkdir()
31
+ (TMP / "llm").mkdir()
32
+ os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="llm",
33
+ BENCHWARMERS_KEY_ANT="sk-ant-test", BENCHWARMERS_KEY_GEM="gm-test")
34
+ sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
35
+
36
+ PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
37
+
38
+
39
+ def sse(events, named=False) -> bytes:
40
+ return b"".join(((f"event: {e['type']}\n" if named and "type" in e else "") + f"data: {json.dumps(e)}\n\n").encode()
41
+ for e in events)
42
+
43
+
44
+ class Native(BaseHTTPRequestHandler):
45
+ """Anthropic under /a/v1, Gemini under /g/v1beta; the model name picks the behaviour."""
46
+ seen: list[tuple[str, dict, dict]] = []
47
+
48
+ def log_message(self, *a):
49
+ pass
50
+
51
+ def _send(self, code: int, body: bytes, ctype: str = "application/json") -> None:
52
+ self.send_response(code)
53
+ self.send_header("Content-Type", ctype)
54
+ self.send_header("Content-Length", str(len(body)))
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ def do_GET(self): # noqa: N802
59
+ Native.seen.append((self.path, {k.lower(): v for k, v in self.headers.items()}, {}))
60
+ if self.path.startswith("/a/v1/models"):
61
+ return self._send(200, json.dumps({"data": [{"id": "claude-x", "type": "model"}]}).encode())
62
+ return self._send(200, json.dumps({"models": [
63
+ {"name": "models/gemini-x", "supportedGenerationMethods": ["generateContent", "countTokens"]},
64
+ {"name": "models/embed-x", "supportedGenerationMethods": ["embedContent"]}]}).encode())
65
+
66
+ def do_POST(self): # noqa: N802
67
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
68
+ Native.seen.append((self.path, {k.lower(): v for k, v in self.headers.items()}, body))
69
+ if self.path.startswith("/a/"):
70
+ model = body["model"]
71
+ if model == "claude-busy":
72
+ return self._send(429, json.dumps({"type": "error", "error": {
73
+ "type": "rate_limit_error", "message": "slow down"}}).encode())
74
+ events = [{"type": "message_start", "message": {"model": "claude-x-20261001", "usage": {"input_tokens": 12}}},
75
+ {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}},
76
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "hmm"}},
77
+ {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}},
78
+ {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "rea"}},
79
+ {"type": "ping"},
80
+ {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "dy"}},
81
+ {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 5}},
82
+ {"type": "message_stop"}]
83
+ if model == "claude-overloaded":
84
+ events = events[:5] + [{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}]
85
+ if model == "claude-cut":
86
+ events = events[:5]
87
+ return self._send(200, sse(events, named=True), "text/event-stream")
88
+ if "gemini-quota" in self.path:
89
+ return self._send(429, json.dumps({"error": {"code": 429, "message": "quota", "status": "RESOURCE_EXHAUSTED"}}).encode())
90
+ chunks = [{"candidates": [{"content": {"role": "model", "parts": [{"text": "thinking", "thought": True}]}}]},
91
+ {"candidates": [{"content": {"role": "model", "parts": [{"text": "rea"}]}}]},
92
+ {"candidates": [{"content": {"role": "model", "parts": [{"text": "dy"}]}, "finishReason": "STOP"}],
93
+ "modelVersion": "gemini-x", "usageMetadata": {"promptTokenCount": 7, "candidatesTokenCount": 2,
94
+ "thoughtsTokenCount": 3}}]
95
+ return self._send(200, sse(chunks), "text/event-stream")
96
+
97
+
98
+ def write(name: str, data) -> None:
99
+ (TMP / "config" / name).write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8")
100
+
101
+
102
+ def main() -> int:
103
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
104
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), Native)
105
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
106
+ base = f"http://127.0.0.1:{srv.server_port}"
107
+ write("endpoints.json", {"ant": {"base": f"{base}/a/v1", "key": "ant", "kind": "provider", "api": "anthropic",
108
+ "default": True},
109
+ "gem": {"base": f"{base}/g/v1beta", "key": "gem", "kind": "provider", "api": "gemini"}})
110
+ import endpoints
111
+ write("extra-openai-models.yaml", "".join(endpoints.yaml_block(a, a, "ant") for a in
112
+ ("claude-x", "claude-busy", "claude-overloaded", "claude-cut"))
113
+ + "".join(endpoints.yaml_block(a, a, "gem") for a in ("gemini-x", "gemini-quota")))
114
+ write("model-options.json", {"claude-x": {"max_tokens": 700, "temperature": 0.3}})
115
+ import client
116
+ import ledger
117
+ try:
118
+ # ANTHROPIC: the native request, its key header, the stream read back.
119
+ heard = []
120
+ res = client.chat("claude-x", "Say ready.", system="Be brief.", timeout=30,
121
+ on_delta=lambda kind, text: heard.append((kind, text)))
122
+ path, headers, body = Native.seen[-1]
123
+ assert path == "/a/v1/messages" and headers["x-api-key"] == "sk-ant-test" and "authorization" not in headers
124
+ assert headers["anthropic-version"] == client.ANTHROPIC_VERSION
125
+ assert body == {"model": "claude-x", "max_tokens": 700, "temperature": 0.3, "stream": True, "system": "Be brief.",
126
+ "messages": [{"role": "user", "content": "Say ready."}]}, body
127
+ assert (res["text"], res["reasoning"], res["model"], res["finish"], res["error"]) == \
128
+ ("ready", "hmm", "claude-x-20261001", "stop", ""), res
129
+ assert res["usage"] == {"input_tokens": 12, "output_tokens": 5} and heard[0] == ("reasoning", "hmm"), (res, heard)
130
+ row = list(ledger.rows(0))[-1]
131
+ assert (row["alias"], row["status"], row["tokens_in"], row["tokens_out"]) == ("claude-x", "ok", 12, 5), row
132
+ assert not row.get("swap") # a dated name is the same model
133
+ # An image goes as Anthropic's own base64 block.
134
+ client.chat("claude-x", "", timeout=30, messages=[{"role": "user", "content": [
135
+ {"type": "text", "text": "what?"}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PIXEL}"}}]}])
136
+ assert Native.seen[-1][2]["messages"][0]["content"][1] == {
137
+ "type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PIXEL}}
138
+ # Errors keep the contract: a status, an error sent mid-stream, a cut stream.
139
+ busy = client.chat("claude-busy", "x", timeout=30, retries=0)
140
+ assert busy["error"].startswith("Error: Error code: 429 - ") and "rate_limit_error" in busy["error"], busy
141
+ over = client.chat("claude-overloaded", "x", timeout=30)
142
+ assert over["error"].startswith("Error: Error code: 529 - ") and "Overloaded" in over["error"], over
143
+ cut = client.chat("claude-cut", "x", timeout=30)
144
+ assert cut["error"].startswith("Error: client failed: the stream broke off"), cut
145
+ # Tools are not sent to a native API from here (route mode passes chat completions only).
146
+ refused = client.chat("claude-x", "x", timeout=30, body={"tools": []})
147
+ assert refused["error"].startswith("Error: client failed: ") and "chat completions only" in refused["error"]
148
+
149
+ # GEMINI: the native request at its own path, its key header, the stream read back.
150
+ res = client.chat("gemini-x", "Say ready.", system="Be brief.", timeout=30)
151
+ path, headers, body = Native.seen[-1]
152
+ assert path == "/g/v1beta/models/gemini-x:streamGenerateContent?alt=sse", path
153
+ assert headers["x-goog-api-key"] == "gm-test" and "authorization" not in headers
154
+ assert body == {"contents": [{"role": "user", "parts": [{"text": "Say ready."}]}],
155
+ "systemInstruction": {"parts": [{"text": "Be brief."}]}}, body
156
+ assert (res["text"], res["reasoning"], res["model"], res["finish"]) == ("ready", "thinking", "gemini-x", "stop")
157
+ assert res["usage"] == {"prompt_tokens": 7, "completion_tokens": 2,
158
+ "completion_tokens_details": {"reasoning_tokens": 3}}, res
159
+ row = list(ledger.rows(0))[-1]
160
+ assert (row["tokens_in"], row["tokens_out"], row["reasoning"]) == (7, 2, 3), row
161
+ quota = client.chat("gemini-quota", "x", timeout=30, retries=0)
162
+ assert quota["error"].startswith("Error: Error code: 429 - ") and "RESOURCE_EXHAUSTED" in quota["error"], quota
163
+
164
+ # Each one's model list in its own shape, asked with its own key header.
165
+ assert list(endpoints.models("ant")) == ["claude-x"] and Native.seen[-1][1]["x-api-key"] == "sk-ant-test"
166
+ assert list(endpoints.models("gem")) == ["gemini-x"] # generateContent models only
167
+ assert Native.seen[-1][0] == "/g/v1beta/models?pageSize=1000"
168
+ # Neither goes through `llm -m`, whatever BENCH_CLIENT says.
169
+ assert client.backend("claude-x") == client.backend("gemini-x") == "builtin" and client.backend() == "llm"
170
+
171
+ # The dashboard's Add form: tested with the API's own key header, saved with the choice.
172
+ import serve
173
+ got = serve.save_endpoint("ant2", f"{base}/a/v1", "sk-ant-2", new=True, api="anthropic", test_only=True)
174
+ # (seen[-1] is the form's second ask, without a key, to say whether the list proves it.)
175
+ assert got["ok"] and got["served"] == 1 and Native.seen[-2][1]["x-api-key"] == "sk-ant-2", got
176
+ assert serve.save_endpoint("ant2", f"{base}/a/v1", "sk-ant-2", new=True, api="anthropic")["ok"]
177
+ assert endpoints.load()["ant2"]["api"] == "anthropic"
178
+ assert not serve.save_endpoint("x2", f"{base}/a/v1", "k", new=True, api="responses")["ok"] # Connect's only
179
+ assert "<select id='e-api'>" in serve.editor()
180
+ finally:
181
+ srv.shutdown()
182
+ shutil.rmtree(TMP, ignore_errors=True)
183
+ print("native Anthropic and Gemini: requests, streams, usage, errors and the Add form hold")
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())
@@ -0,0 +1,334 @@
1
+ """The built-in client's Phase 2b, on a fake server: streaming, the usage
2
+ ledger, quota records, pacing and the free-tier guard.
3
+
4
+ python scripts/test_quota.py
5
+
6
+ No model is called and nothing of the install's is read or written:
7
+ BENCHWARMERS_HOME and LLM_USER_PATH name temp folders before any module
8
+ reads them. Each scenario is one model name the fake server answers in its
9
+ own way; the pacing ones sit on endpoints of their own, so their counts do
10
+ not mix. Covered:
11
+
12
+ - a streamed answer: text and reasoning apart, each piece heard by on_delta,
13
+ usage from the final chunk, the first token's time; and its ledger line;
14
+ - an upstream that answers plain JSON to a stream request, and one that
15
+ refuses stream_options (asked again without);
16
+ - a reply naming another model: the call succeeds, the swap is noted;
17
+ - an empty answer cut off by its budget: tried once more with twice the budget;
18
+ - a stream that breaks off, and STOP between chunks;
19
+ - quota records from rate-limit headers, the next call waiting out the reset;
20
+ a Gemini 429 body's daily quota learned as a limit;
21
+ - an rpm limit across six threads at once and across two processes (calls in
22
+ flight count), a monthly cap (rpmo), and both refusals classed as capacity;
23
+ - the free-tier guard holding a model that went paid, until it is allowed;
24
+ - metrics/cost.py reading the ledger.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import multiprocessing
31
+ import os
32
+ import pathlib
33
+ import sys
34
+ import tempfile
35
+ import threading
36
+ import time
37
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
+
39
+ # A child process (the cross-process check) re-runs this module: it must find the parent's folder.
40
+ TMP = pathlib.Path(os.environ.get("BENCH_QUOTA_TMP") or tempfile.mkdtemp(prefix="bench-quota-"))
41
+ os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="builtin",
42
+ BENCH_QUOTA_TMP=str(TMP))
43
+ os.environ.pop("BENCH_ALLOW_PAID", None)
44
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
45
+ sys.path[:0] = [str(ROOT / "metrics"), str(ROOT / "suite")]
46
+
47
+ import alerts # noqa: E402
48
+ import client # noqa: E402
49
+ import cost # noqa: E402
50
+ import learn # noqa: E402
51
+ import ledger # noqa: E402
52
+ import quota # noqa: E402
53
+ import run # noqa: E402
54
+
55
+ seen: dict[str, list[dict]] = {}
56
+ lock = threading.Lock()
57
+
58
+
59
+ class Fake(BaseHTTPRequestHandler):
60
+ def log_message(self, *_):
61
+ pass
62
+
63
+ def _send(self, code: int, body: bytes, ctype: str = "application/json", extra=None):
64
+ self.send_response(code)
65
+ self.send_header("Content-Type", ctype)
66
+ self.send_header("Content-Length", str(len(body)))
67
+ for k, v in (extra or {}).items():
68
+ self.send_header(k, v)
69
+ self.end_headers()
70
+ self.wfile.write(body)
71
+
72
+ def _stream(self, model: str, deltas: list[dict], finish: str = "stop", usage=None, served=None,
73
+ extra=None, gap: float = 0.0, close_early: bool = False):
74
+ self.send_response(200)
75
+ self.send_header("Content-Type", "text/event-stream")
76
+ for k, v in (extra or {}).items():
77
+ self.send_header(k, v)
78
+ self.end_headers()
79
+ head = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": served or model}
80
+ for d in deltas:
81
+ self.wfile.write(f"data: {json.dumps({**head, 'choices': [{'index': 0, 'delta': d}]})}\n\n".encode())
82
+ self.wfile.flush()
83
+ time.sleep(gap)
84
+ if close_early:
85
+ return # HTTP/1.0: the connection closes, no finish, no [DONE]
86
+ tail = [{**head, "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]},
87
+ {**head, "choices": [], "usage": usage or {"prompt_tokens": 7, "completion_tokens": 4,
88
+ "total_tokens": 11}}]
89
+ self.wfile.write(("".join(f"data: {json.dumps(c)}\n\n" for c in tail) + "data: [DONE]\n\n").encode())
90
+
91
+ def do_POST(self): # noqa: N802
92
+ req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
93
+ model = req["model"]
94
+ with lock:
95
+ calls = seen.setdefault(model, [])
96
+ calls.append(req)
97
+ n = len(calls)
98
+ try:
99
+ if model == "plain" or (model == "nooptions" and "stream_options" in req):
100
+ if model == "nooptions":
101
+ return self._send(400, b'{"error": {"message": "Unrecognized request argument supplied:'
102
+ b' stream_options"}}')
103
+ return self._send(200, json.dumps({"model": model, "choices": [{"message": {
104
+ "content": "plain answer"}, "finish_reason": "stop"}],
105
+ "usage": {"prompt_tokens": 2, "completion_tokens": 2}}).encode())
106
+ if model == "overcap" and (req.get("max_tokens") or 0) >= 100:
107
+ return self._send(400, b'{"error": {"message": "max_tokens is too large: 128"}}')
108
+ if model in ("budget", "overcap") and (req.get("max_tokens") or 0) < 100:
109
+ return self._stream(model, [{"reasoning_content": "thinking and thinking"}], finish="length",
110
+ usage={"prompt_tokens": 5, "completion_tokens": req.get("max_tokens")})
111
+ if model == "ratelimited" and n <= 3:
112
+ return self._send(429, b'{"error": {"message": "slow down", "code": 429}}', extra={
113
+ "Retry-After": "0.1", "x-ratelimit-limit-requests": "3",
114
+ "x-ratelimit-remaining-requests": "0", "x-ratelimit-reset-requests": "2s"})
115
+ if model == "gemini":
116
+ return self._send(429, json.dumps([{"error": {"code": 429, "status": "RESOURCE_EXHAUSTED",
117
+ "message": "Quota exceeded", "details": [
118
+ {"@type": "type.googleapis.com/google.rpc.QuotaFailure", "violations": [
119
+ {"quotaId": "GenerateRequestsPerDayPerProjectPerModel-FreeTier",
120
+ "quotaValue": "50"}]},
121
+ {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}]}}]).encode())
122
+ if model == "paced":
123
+ time.sleep(0.4) # long enough that the seats overlap
124
+ if model == "dribble":
125
+ return self._stream(model, [{"content": "x"}] * 25, gap=0.2)
126
+ if model == "breakoff":
127
+ return self._stream(model, [{"content": "half an answer"}], close_early=True)
128
+ return self._stream(model, [{"reasoning_content": "let me "}, {"reasoning_content": "see"},
129
+ {"content": "The answer "}, {"content": "is ready."}],
130
+ served="someone-else-7b" if model == "swapped" else None,
131
+ extra={"x-ratelimit-remaining-requests": "99", "x-ratelimit-limit-requests": "100",
132
+ "x-ratelimit-reset-requests": "6m0s"} if model == "ok" else None)
133
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
134
+ pass
135
+
136
+
137
+ def setup(port: int) -> None:
138
+ base = f"http://127.0.0.1:{port}"
139
+ eps = {"fakeprov": f"{base}/v1", "pacedprov": f"{base}/p/v1", "procprov": f"{base}/q/v1",
140
+ "monthprov": f"{base}/m/v1"}
141
+ (TMP / "config").mkdir(parents=True, exist_ok=True)
142
+ (TMP / "llm").mkdir(parents=True, exist_ok=True)
143
+ (TMP / "config" / "endpoints.json").write_text(json.dumps(
144
+ {n: {"base": b, "key": "fake", "kind": "provider", **({"default": True} if n == "fakeprov" else {})}
145
+ for n, b in eps.items()}), encoding="utf-8")
146
+ where = {"paced": "pacedprov", "proc": "procprov", "month": "monthprov"}
147
+ yaml = []
148
+ for m in ("ok", "plain", "nooptions", "swapped", "budget", "overcap", "ratelimited", "gemini", "dribble", "breakoff",
149
+ "paced", "proc", "month", "paid"):
150
+ yaml += [f"- model_id: t-{m}", f" model_name: {'paced' if m in ('proc', 'month') else m}",
151
+ f' api_base: "{eps[where.get(m, "fakeprov")]}"', " api_key_name: fake"]
152
+ (TMP / "config" / "extra-openai-models.yaml").write_text("\n".join(yaml) + "\n", encoding="utf-8")
153
+ (TMP / "config" / "model-options.json").write_text('{"t-budget": {"max_tokens": 64}, "t-overcap": {"max_tokens": 64}}',
154
+ encoding="utf-8")
155
+ (TMP / "config" / "connection-limits.json").write_text(json.dumps(
156
+ {"pacedprov": {"rpm": 3}, "procprov": {"rpm": 4}, "monthprov": {"rpmo": 3}}), encoding="utf-8")
157
+ (TMP / "llm" / "keys.json").write_text('{"fake": "sk-test-not-a-key"}', encoding="utf-8")
158
+
159
+
160
+ def _child(n: int) -> None:
161
+ """One process's calls, for the cross-process pacing check."""
162
+ for _ in range(n):
163
+ client.chat("t-proc", "hi", timeout=5)
164
+
165
+
166
+ def main() -> int:
167
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
168
+ srv.daemon_threads = True
169
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
170
+ setup(srv.server_address[1])
171
+ failures = 0
172
+
173
+ def check(ok: bool, what: str, detail="") -> None:
174
+ nonlocal failures
175
+ print(("ok " if ok else "FAIL ") + what + ("" if ok else f"\n {detail}"))
176
+ failures += not ok
177
+
178
+ def last(alias: str) -> dict:
179
+ return [r for r in ledger.rows() if r["alias"] == alias][-1]
180
+
181
+ # Streaming: text and reasoning apart, every piece heard, usage from the last chunk.
182
+ heard = []
183
+ r = client.chat("t-ok", "Say something.", on_delta=lambda k, t: heard.append((k, t)), purpose="test")
184
+ req = seen["ok"][-1]
185
+ check(r["text"] == "The answer is ready." and r["reasoning"] == "let me see" and r["finish"] == "stop",
186
+ "a streamed answer, its reasoning kept apart", r)
187
+ check(req.get("stream") is True and req.get("stream_options") == {"include_usage": True},
188
+ "asked for a stream with usage", req)
189
+ check(heard == [("reasoning", "let me "), ("reasoning", "see"), ("text", "The answer "), ("text", "is ready.")],
190
+ "on_delta heard each piece in order", heard)
191
+ check(r["usage"].get("completion_tokens") == 4 and r["first_token_s"] is not None and r["attempts"] == 1,
192
+ "usage from the final chunk, first token timed", r)
193
+ row = last("t-ok")
194
+ check(row["status"] == "ok" and row["tokens_in"] == 7 and row["tokens_out"] == 4 and row["attempts"] == 1
195
+ and row["connection"] == "fakeprov" and row["caller"] == "scripts/test_quota" and row["purpose"] == "test",
196
+ "a ledger line with tokens, connection and caller", row)
197
+ recs = quota.live("fakeprov", "t-ok")
198
+ check(len(recs) == 1 and recs[0]["remaining"] == 99 and recs[0]["limit"] == 100,
199
+ "the reply's rate-limit headers kept as a quota record", recs)
200
+
201
+ # Upstreams that do not stream.
202
+ r = client.chat("t-plain", "hi")
203
+ check(r["text"] == "plain answer" and not r["error"], "plain JSON to a stream request is read as before", r)
204
+ r = client.chat("t-nooptions", "hi")
205
+ again = seen["nooptions"][-1]
206
+ check(r["text"] == "The answer is ready." and r["attempts"] == 2 and "stream_options" not in again
207
+ and again.get("stream") is True, "stream_options refused: asked once more without, still streamed", r)
208
+
209
+ # A silent swap: the call succeeds and is noted.
210
+ r = client.chat("t-swapped", "hi")
211
+ swap = (learn.learned().get("t-swapped") or {}).get("served_as") or {}
212
+ check(r["text"] and r["model"] == "someone-else-7b" and swap.get("served") == "someone-else-7b"
213
+ and last("t-swapped").get("swap") is True, "a swapped model is noted, the answer kept", (r, swap))
214
+
215
+ # An empty answer that ran out of budget: once more with twice the budget.
216
+ r = client.chat("t-budget", "hi")
217
+ tried = [c.get("max_tokens") for c in seen["budget"]]
218
+ check(r["text"] == "The answer is ready." and tried == [64, 128]
219
+ and r.get("budget_retry") == {"max_tokens": [64, 128], "answered": True}
220
+ and last("t-budget")["attempts"] == 2, "an empty reply cut by its budget is retried once, larger",
221
+ (tried, r))
222
+ r = client.chat("t-overcap", "hi")
223
+ check(r["text"] == "" and not r["error"] and r["budget_retry"]["answered"] is False
224
+ and "too large" in r["budget_retry"]["failed"] and run.failure_class(client.output(r, 30)) == "empty",
225
+ "a budget retry that fails leaves the empty answer, not an error", r)
226
+
227
+ # A stream that breaks off, and STOP between chunks.
228
+ r = client.chat("t-breakoff", "hi", timeout=10)
229
+ check("broke off before it finished" in r["error"] and run.failure_class(client.output(r, 10)) == "other",
230
+ "a stream that breaks off is an error, not a short answer", r["error"])
231
+ threading.Timer(0.6, client.STOP.set).start()
232
+ t = time.monotonic()
233
+ r = client.chat("t-dribble", "hi", timeout=30)
234
+ client.STOP.clear()
235
+ check(r["timed_out"] and time.monotonic() - t < 3, f"STOP ends a stream between chunks ({time.monotonic() - t:.1f}s)", r)
236
+
237
+ # Quota records: a 429's headers, then the next call waits for the reset.
238
+ r = client.chat("t-ratelimited", "hi", timeout=20)
239
+ # Read whole: the Retry-After record (0.1 s) has passed already, so live() leaves it out.
240
+ recs = (quota.locked.read_json(quota.FILE).get("fakeprov") or {}).get("t-ratelimited") or {}
241
+ check(r["status"] == 429 and r["attempts"] == 3 and recs.get("requests/unknown", {}).get("remaining") == 0
242
+ and "requests/retry-after" in recs, "a 429's rate-limit headers kept as quota records", (r, recs))
243
+ t = time.monotonic()
244
+ r = client.chat("t-ratelimited", "hi", timeout=20)
245
+ waited = time.monotonic() - t
246
+ check(r["text"] and r["attempts"] == 1 and 1.0 <= waited < 6,
247
+ f"the next call waited out the stated reset, then answered ({waited:.1f}s)", r)
248
+ r = client.chat("t-gemini", "hi", timeout=20)
249
+ rpd = ((learn.learned().get("t-gemini") or {}).get("limits") or {}).get("rpd") or {}
250
+ check(r["status"] == 429 and rpd.get("value") == 50 and rpd.get("where") == "quota",
251
+ "a Gemini 429 body's daily quota is learned as the model's rpd", rpd)
252
+
253
+ # Pacing across threads: rpm 3, six seats at once, the server slow enough that they overlap.
254
+ out: list[dict] = []
255
+ seats = [threading.Thread(target=lambda: out.append(client.chat("t-paced", "hi", timeout=5)))
256
+ for _ in range(6)]
257
+ for s in seats:
258
+ s.start()
259
+ for s in seats:
260
+ s.join()
261
+ answered = [x for x in out if x["text"]]
262
+ refused = [x for x in out if "rate limit (not sent" in x["error"]]
263
+ check(len(answered) == 3 and len(refused) == 3 and len(seen["paced"]) == 3,
264
+ f"rpm 3 across six threads: {len(answered)} sent, {len(refused)} refused", [x["error"][:80] for x in out])
265
+ if refused:
266
+ body = client.output(refused[0], 5)
267
+ check(run.failure_class(body) == "capacity" and run.outcome(body)[0] == "error"
268
+ and "try again in" in body and learn.parse(body).get("limits") is None,
269
+ "the refusal is a capacity failure, with a wait and no new learned limit", body)
270
+ check(last("t-paced")["status"] in ("refused", "ok") and
271
+ sum(1 for x in ledger.rows() if x["alias"] == "t-paced" and x["status"] == "refused") == 3,
272
+ "refused calls are in the ledger, sending nothing")
273
+
274
+ # Across processes: rpm 4 on procprov, two processes of three calls.
275
+ procs = [multiprocessing.Process(target=_child, args=(3,)) for _ in range(2)]
276
+ for p in procs:
277
+ p.start()
278
+ for p in procs:
279
+ p.join()
280
+ rows = [x for x in ledger.rows() if x["connection"] == "procprov"]
281
+ sent = sum(1 for x in rows if x["status"] == "ok")
282
+ check(all(p.exitcode == 0 for p in procs) and sent == 4 and len(rows) == 6,
283
+ f"rpm 4 across two processes: {sent} of {len(rows)} sent", rows)
284
+
285
+ # A monthly cap: three this month already, so the next waits for next month, which no deadline fits.
286
+ now = time.time()
287
+ for _ in range(3):
288
+ ledger.append({"t": now - 1, "alias": "t-month", "connection": "monthprov", "status": "ok", "attempts": 1})
289
+ r = client.chat("t-month", "hi", timeout=30)
290
+ check("rate limit (not sent" in r["error"] and "a calendar month" in r["error"] and "this month" in r["error"],
291
+ "rpmo 3: the fourth call this month is refused", r["error"])
292
+ check(client.chat("t-ok", "hi")["text"] != "", "another connection is not held by that cap")
293
+
294
+ # The free-tier guard: a model that went paid is held until allowed.
295
+ before = len(seen.get("paid", []))
296
+ alerts.raise_alert("model:paid:paid", "went paid", "t-paid is listed with a price now (models.dev)",
297
+ "check the provider", alias="t-paid")
298
+ r = client.chat("t-paid", "hi")
299
+ body = client.output(r, 30)
300
+ check("free-tier guard" in r["error"] and len(seen.get("paid", [])) == before
301
+ and run.failure_class(body) == "entitlement" and last("t-paid")["status"] == "refused",
302
+ "the free-tier guard holds a model that went paid, as entitlement", body)
303
+ (TMP / "config" / "connections.json").write_text('{"allow_paid": ["t-paid"]}', encoding="utf-8")
304
+ check(client.chat("t-paid", "hi")["text"] != "", "allow_paid lets it through")
305
+
306
+ # cost.py reads the ledger.
307
+ data = cost.collect()
308
+ ok = data.get("t-ok", {})
309
+ check(ok.get("calls", 0) >= 2 and ok.get("input", 0) >= 14 and ok.get("output", 0) >= 8,
310
+ "cost.py counts the ledger's calls and tokens", ok)
311
+
312
+ # The dashboard: the Endpoints dialog's limits take rpmo; the Quotas page shows records and the ledger.
313
+ sys.path.insert(0, str(ROOT))
314
+ import serve
315
+ saved = serve.save_connection_limits("monthprov", {"rpm": 20, "rpmo": 1000})
316
+ view = {c["name"]: c for c in serve.connections_view()["connections"]}
317
+ check(saved["ok"] and "rpmo" in serve.connections_view()["keys"]
318
+ and view.get("monthprov", {}).get("limits") == {"rpm": 20, "rpmo": 1000},
319
+ "the Endpoints dialog saves a monthly cap (rpmo)", (saved, view.get("monthprov")))
320
+ bad = serve.save_connection_limits("monthprov", {"rpmo": -1})
321
+ check(not bad["ok"] and serve.save_model_facts("t-ok", {"limits": {"rpmo": 5}}).get("ok") is not None
322
+ and "rpmo" not in ((serve.catalog.overrides().get("t-ok") or {}).get("limits") or {}),
323
+ "a bad rpmo is refused; a model's own limits have none", bad)
324
+ page = serve.quotas_page()
325
+ check("What providers said is left" in page and "fakeprov" in page and "What the bench sent" in page,
326
+ "the Quotas page shows the records and the ledger's counts")
327
+
328
+ srv.shutdown()
329
+ print("\nthe ledger, quotas and pacing hold" if not failures else f"\n{failures} FAILED")
330
+ return 1 if failures else 0
331
+
332
+
333
+ if __name__ == "__main__":
334
+ sys.exit(main())