@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
@@ -1,233 +1,277 @@
1
- """The built-in client against `llm`, on a fake OpenAI-compatible server.
2
-
3
- python scripts/test_client.py
4
-
5
- No model is called and nothing of the owner's is read or written: `llm` runs
6
- with LLM_USER_PATH pointed at a temp folder holding its own registry, key and
7
- options, and metrics/client.py is pointed at the same files. Each scenario is
8
- one model name the fake server answers in its own way (a reply, an empty
9
- reply, a 404, a 429 that never clears, one that clears, an HTML 502, a reply
10
- too slow for the deadline, a credit notice), plus an endpoint that refuses the
11
- connection.
12
-
13
- For every scenario both backends go through suite/run.py's ask_once and must
14
- land in the same place: the same `outcome` and `failure_class`, at the suite's
15
- length floor and the perf tier's, and the same text when the model answered.
16
- That is what Phase 2a promises before the client may become the default. The
17
- requests are compared too: model, messages, options, key and headers.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import json
23
- import os
24
- import pathlib
25
- import socket
26
- import sys
27
- import tempfile
28
- import threading
29
- import time
30
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31
-
32
- ROOT = pathlib.Path(__file__).resolve().parent.parent
33
- sys.path[:0] = [str(ROOT / "metrics"), str(ROOT / "suite")]
34
- import client # noqa: E402
35
- import endpoints # noqa: E402
36
- import run # noqa: E402
37
-
38
- ANSWER = "The quick brown fox jumps over the lazy dog, and then does it twice."
39
- seen: dict[tuple[str, str], list[dict]] = {} # (backend, model) -> requests
40
- OTHER = "" # the second server's base, set in main()
41
- leaked: list[str] = [] # Authorization headers the other origin received
42
-
43
-
44
- class Other(BaseHTTPRequestHandler):
45
- def log_message(self, *_):
46
- pass
47
-
48
- def _any(self):
49
- leaked.append(self.headers.get("Authorization") or "")
50
- self.send_response(404)
51
- self.send_header("Content-Length", "0")
52
- self.end_headers()
53
-
54
- do_GET = do_POST = _any
55
-
56
-
57
- class Fake(BaseHTTPRequestHandler):
58
- def log_message(self, *_):
59
- pass
60
-
61
- def _send(self, code: int, body: bytes, ctype: str = "application/json", extra=None):
62
- self.send_response(code)
63
- self.send_header("Content-Type", ctype)
64
- self.send_header("Content-Length", str(len(body)))
65
- for k, v in (extra or {}).items():
66
- self.send_header(k, v)
67
- self.end_headers()
68
- self.wfile.write(body)
69
-
70
- def do_POST(self): # noqa: N802
71
- req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
72
- who = "llm" if "OpenAI" in (self.headers.get("User-Agent") or "") else "builtin"
73
- calls = seen.setdefault((who, req["model"]), [])
74
- calls.append({"body": req, "headers": {k.lower(): v for k, v in self.headers.items()}})
75
- model = req["model"]
76
- try:
77
- if model == "redirect": # to another origin: the key must not follow
78
- self.send_response(302)
79
- self.send_header("Location", f"{OTHER}/chat/completions")
80
- self.send_header("Content-Length", "0")
81
- self.end_headers()
82
- return
83
- if model == "trickle": # headers at once, then a byte a while
84
- self.send_response(200)
85
- self.send_header("Content-Type", "application/json")
86
- self.send_header("Content-Length", "1000")
87
- self.end_headers()
88
- for _ in range(40):
89
- self.wfile.write(b" ")
90
- self.wfile.flush()
91
- time.sleep(0.25)
92
- return
93
- if model == "cutoff": # an error whose body breaks off
94
- self.send_response(502)
95
- self.send_header("Content-Length", "100")
96
- self.end_headers()
97
- self.wfile.write(b"<html>")
98
- self.wfile.flush()
99
- self.connection.shutdown(socket.SHUT_RDWR)
100
- return
101
- if model == "nochoices":
102
- return self._send(200, b"{}")
103
- if model == "notfound":
104
- return self._send(404, b'{"error": {"message": "Model not found", "code": 404}}')
105
- if model == "ratelimit":
106
- return self._send(429, json.dumps({"error": {
107
- "message": "Rate limit exceeded: free-models-per-day", "code": 429}}).encode(),
108
- extra={"Retry-After": "1"})
109
- if model == "flaky" and len(calls) == 1:
110
- return self._send(429, b'{"error": {"message": "slow down", "code": 429}}',
111
- extra={"Retry-After": "1"})
112
- if model == "html502":
113
- return self._send(502, b"<html><head><title>502 Bad Gateway</title></head></html>",
114
- "text/html")
115
- if model == "slow":
116
- time.sleep(6)
117
- content = {"empty": None, "short": "ready",
118
- "notice": "You have run out of credits. Please add credits."}.get(model, ANSWER)
119
- message = {"role": "assistant", "content": content}
120
- if model == "empty":
121
- message["reasoning_content"] = "thinking, and out of budget"
122
- if req.get("stream"):
123
- head = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": model}
124
- chunks = [{**head, "choices": [{"index": 0, "delta": message, "finish_reason": None}]},
125
- {**head, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
126
- {**head, "choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 5,
127
- "total_tokens": 8}}]
128
- body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n"
129
- return self._send(200, body.encode(), "text/event-stream")
130
- return self._send(200, json.dumps({
131
- "id": "c1", "object": "chat.completion", "created": 0, "model": model,
132
- "choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
133
- "usage": {"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8}}).encode())
134
- except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
135
- pass # the caller gave up on "slow"
136
-
137
-
138
- def free_port() -> int:
139
- with socket.socket() as s:
140
- s.bind(("127.0.0.1", 0))
141
- return s.getsockname()[1]
142
-
143
-
144
- def main() -> int:
145
- global OTHER
146
- srv = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
147
- other = ThreadingHTTPServer(("127.0.0.1", 0), Other)
148
- for s in (srv, other):
149
- s.daemon_threads = True
150
- threading.Thread(target=s.serve_forever, daemon=True).start()
151
- live, dead = f"http://127.0.0.1:{srv.server_address[1]}/v1", f"http://127.0.0.1:{free_port()}/v1"
152
- OTHER = f"http://localhost:{other.server_address[1]}/v1"
153
-
154
- tmp = pathlib.Path(tempfile.mkdtemp(prefix="bench-client-"))
155
- scenarios = ["ok", "short", "empty", "notfound", "ratelimit", "flaky", "html502", "slow", "notice",
156
- "refused", "nokey"]
157
- own = ["redirect", "trickle", "cutoff", "nochoices"] # the built-in client only
158
- yaml = []
159
- for s in scenarios + own:
160
- yaml += [f"- model_id: t-{s}", f" model_name: {'ok' if s in ('refused', 'nokey') else s}",
161
- f' api_base: "{dead if s == "refused" else live}"',
162
- f" api_key_name: {'unset' if s == 'nokey' else 'fake'}"]
163
- if s == "ok":
164
- yaml += [" headers:", ' X-Title: "bench test"']
165
- (tmp / "extra-openai-models.yaml").write_text("\n".join(yaml) + "\n", encoding="utf-8")
166
- (tmp / "keys.json").write_text('{"fake": "sk-test-not-a-key"}', encoding="utf-8")
167
- (tmp / "model_options.json").write_text('{"t-ok": {"max_tokens": "123"}}', encoding="utf-8")
168
- (tmp / "options.json").write_text('{"t-ok": {"max_tokens": 123}}', encoding="utf-8")
169
- os.environ["LLM_USER_PATH"] = str(tmp) # llm reads only the temp folder
170
- endpoints.YAML, endpoints.LLM_DIR, client.OPTIONS = (tmp / "extra-openai-models.yaml", tmp,
171
- tmp / "options.json")
172
-
173
- binary, failures, prompt = run.llm_binary(), 0, "Say something."
174
- for s in scenarios:
175
- bodies = {}
176
- for backend in ("llm", "builtin"):
177
- os.environ["BENCH_CLIENT"] = backend
178
- # On Windows a refused connection takes about 2 s and the SDK retries:
179
- # llm needed 19-41 s for "refused" there, past a 30 s deadline.
180
- bodies[backend], _ = run.ask_once(binary, f"t-{s}", prompt, {"slow": 4, "refused": 90}.get(s, 30))
181
- a, b = bodies["llm"], bodies["builtin"]
182
-
183
- def kinds(x):
184
- return ((run.outcome(x) or ("answered",))[0], (run.outcome(x, 1) or ("answered",))[0],
185
- run.failure_class(x), run.failure_class(x, 1))
186
- same = kinds(a) == kinds(b) and (a == b or kinds(a)[1] != "answered")
187
- print(("ok " if same else "FAIL ") + f"{s:<10} {kinds(b)}")
188
- if not same:
189
- print(f" llm: {a[:150]!r}\n builtin: {b[:150]!r}")
190
- failures += not same
191
-
192
- # The request itself: same model, messages, options, key and headers.
193
- sent = {who: seen[(who, "ok")][-1] for who in ("llm", "builtin")}
194
- want = {k: sent["llm"]["body"].get(k) for k in ("model", "messages", "max_tokens")}
195
- got = {k: sent["builtin"]["body"].get(k) for k in ("model", "messages", "max_tokens")}
196
- hdr = [(sent[w]["headers"].get("authorization"), sent[w]["headers"].get("x-title")) for w in sent]
197
- ok = want == got and want["max_tokens"] == 123 and hdr[0] == hdr[1] == ("Bearer sk-test-not-a-key",
198
- "bench test")
199
- print(("ok " if ok else "FAIL ") + f"request matches: {got}, {hdr[1]}"
200
- + ("" if ok else f"\n llm sent: {want}, {hdr[0]}"))
201
- failures += not ok
202
- # Retries as the SDK does them: three tries at a 429 that never clears.
203
- tries = [len(seen.get((w, "ratelimit"), [])) for w in ("llm", "builtin")]
204
- ok = tries == [3, 3]
205
- print(("ok " if ok else "FAIL ") + f"429 tried {tries[1]} times (llm {tries[0]})")
206
- failures += not ok
207
-
208
- # Where the built-in client is stricter than urllib's defaults would be.
209
- r = client.chat("t-redirect", prompt, timeout=10)
210
- ok = r["status"] == 302 and "not followed" in r["error"] and not any(leaked)
211
- print(("ok " if ok else "FAIL ") + f"redirect refused, key kept: {r['error'][:60]!r}")
212
- failures += not ok
213
- t = time.monotonic()
214
- r = client.chat("t-trickle", prompt, timeout=3)
215
- ok = r["timed_out"] and time.monotonic() - t < 5
216
- print(("ok " if ok else "FAIL ") + f"trickling reply cut at the deadline ({time.monotonic() - t:.1f}s)")
217
- failures += not ok
218
- r = client.chat("t-cutoff", prompt, timeout=20)
219
- ok = r["error"].startswith("Error: Error code: 502") and run.failure_class(client.output(r, 20)) == "capacity"
220
- print(("ok " if ok else "FAIL ") + f"broken-off error body, no crash: {r['error'][:40]!r}")
221
- failures += not ok
222
- r = client.chat("t-nochoices", prompt, timeout=10)
223
- ok = "no choices" in r["error"] and run.failure_class(client.output(r, 10)) == "other"
224
- print(("ok " if ok else "FAIL ") + "a 200 with no choice is an error, not an empty answer")
225
- failures += not ok
226
-
227
- srv.shutdown()
228
- print("\nthe built-in client lands where llm does" if not failures else f"\n{failures} FAILED")
229
- return 1 if failures else 0
230
-
231
-
232
- if __name__ == "__main__":
233
- sys.exit(main())
1
+ """The built-in client against `llm`, on a fake OpenAI-compatible server.
2
+
3
+ python scripts/test_client.py
4
+
5
+ No model is called and nothing of the owner's is read or written: `llm` runs
6
+ with LLM_USER_PATH pointed at a temp folder holding its own registry, key and
7
+ options, and metrics/client.py is pointed at the same files. Each scenario is
8
+ one model name the fake server answers in its own way (a reply, an empty
9
+ reply, a 404, a 429 that never clears, one that clears, an HTML 502, a reply
10
+ too slow for the deadline, a credit notice), plus an endpoint that refuses the
11
+ connection.
12
+
13
+ For every scenario both backends go through suite/run.py's ask_once and must
14
+ land in the same place: the same `outcome` and `failure_class`, at the suite's
15
+ length floor and the perf tier's, and the same text when the model answered.
16
+ That is what Phase 2a promises before the client may become the default. The
17
+ requests are compared too: model, messages, options, key and headers, and
18
+ that both ask for a stream (Phase 2b). Then the built-in client's own: the
19
+ usage from the stream's last chunk and its ledger line, and a call refused
20
+ by pacing, which must land where llm's real 429 does.
21
+
22
+ The data folder is a temp one too (BENCHWARMERS_HOME, set before any module
23
+ reads it): the built-in client now writes a ledger line and quota records
24
+ for every call.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import pathlib
32
+ import socket
33
+ import sys
34
+ import tempfile
35
+ import threading
36
+ import time
37
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
+
39
+ DATA = pathlib.Path(tempfile.mkdtemp(prefix="bench-client-data-"))
40
+ os.environ["BENCHWARMERS_HOME"] = str(DATA)
41
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
42
+ sys.path[:0] = [str(ROOT / "metrics"), str(ROOT / "suite")]
43
+ import client # noqa: E402
44
+ import endpoints # noqa: E402
45
+ import ledger # noqa: E402
46
+ import run # noqa: E402
47
+
48
+ ANSWER = "The quick brown fox jumps over the lazy dog, and then does it twice."
49
+ seen: dict[tuple[str, str], list[dict]] = {} # (backend, model) -> requests
50
+ OTHER = "" # the second server's base, set in main()
51
+ leaked: list[str] = [] # Authorization headers the other origin received
52
+
53
+
54
+ class Other(BaseHTTPRequestHandler):
55
+ def log_message(self, *_):
56
+ pass
57
+
58
+ def _any(self):
59
+ leaked.append(self.headers.get("Authorization") or "")
60
+ self.send_response(404)
61
+ self.send_header("Content-Length", "0")
62
+ self.end_headers()
63
+
64
+ do_GET = do_POST = _any
65
+
66
+
67
+ class Fake(BaseHTTPRequestHandler):
68
+ def log_message(self, *_):
69
+ pass
70
+
71
+ def _send(self, code: int, body: bytes, ctype: str = "application/json", extra=None):
72
+ self.send_response(code)
73
+ self.send_header("Content-Type", ctype)
74
+ self.send_header("Content-Length", str(len(body)))
75
+ for k, v in (extra or {}).items():
76
+ self.send_header(k, v)
77
+ self.end_headers()
78
+ self.wfile.write(body)
79
+
80
+ def do_POST(self): # noqa: N802
81
+ req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
82
+ who = "llm" if "OpenAI" in (self.headers.get("User-Agent") or "") else "builtin"
83
+ calls = seen.setdefault((who, req["model"]), [])
84
+ calls.append({"body": req, "headers": {k.lower(): v for k, v in self.headers.items()}})
85
+ model = req["model"]
86
+ try:
87
+ if model == "redirect": # to another origin: the key must not follow
88
+ self.send_response(302)
89
+ self.send_header("Location", f"{OTHER}/chat/completions")
90
+ self.send_header("Content-Length", "0")
91
+ self.end_headers()
92
+ return
93
+ if model == "trickle": # headers at once, then a byte a while
94
+ self.send_response(200)
95
+ self.send_header("Content-Type", "application/json")
96
+ self.send_header("Content-Length", "1000")
97
+ self.end_headers()
98
+ for _ in range(40):
99
+ self.wfile.write(b" ")
100
+ self.wfile.flush()
101
+ time.sleep(0.25)
102
+ return
103
+ if model == "cutoff": # an error whose body breaks off
104
+ self.send_response(502)
105
+ self.send_header("Content-Length", "100")
106
+ self.end_headers()
107
+ self.wfile.write(b"<html>")
108
+ self.wfile.flush()
109
+ self.connection.shutdown(socket.SHUT_RDWR)
110
+ return
111
+ if model == "nochoices":
112
+ return self._send(200, b"{}")
113
+ if model == "notfound":
114
+ return self._send(404, b'{"error": {"message": "Model not found", "code": 404}}')
115
+ if model == "ratelimit":
116
+ return self._send(429, json.dumps({"error": {
117
+ "message": "Rate limit exceeded: free-models-per-day", "code": 429}}).encode(),
118
+ extra={"Retry-After": "1"})
119
+ if model == "flaky" and len(calls) == 1:
120
+ return self._send(429, b'{"error": {"message": "slow down", "code": 429}}',
121
+ extra={"Retry-After": "1"})
122
+ if model == "html502":
123
+ return self._send(502, b"<html><head><title>502 Bad Gateway</title></head></html>",
124
+ "text/html")
125
+ if model == "slow":
126
+ time.sleep(6)
127
+ content = {"empty": None, "short": "ready",
128
+ "notice": "You have run out of credits. Please add credits."}.get(model, ANSWER)
129
+ message = {"role": "assistant", "content": content}
130
+ if model == "empty":
131
+ message["reasoning_content"] = "thinking, and out of budget"
132
+ if req.get("stream"):
133
+ head = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": model}
134
+ chunks = [{**head, "choices": [{"index": 0, "delta": message, "finish_reason": None}]},
135
+ {**head, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
136
+ {**head, "choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 5,
137
+ "total_tokens": 8}}]
138
+ body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n"
139
+ return self._send(200, body.encode(), "text/event-stream")
140
+ return self._send(200, json.dumps({
141
+ "id": "c1", "object": "chat.completion", "created": 0, "model": model,
142
+ "choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
143
+ "usage": {"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8}}).encode())
144
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
145
+ pass # the caller gave up on "slow"
146
+
147
+
148
+ def free_port() -> int:
149
+ with socket.socket() as s:
150
+ s.bind(("127.0.0.1", 0))
151
+ return s.getsockname()[1]
152
+
153
+
154
+ def main() -> int:
155
+ global OTHER
156
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
157
+ other = ThreadingHTTPServer(("127.0.0.1", 0), Other)
158
+ for s in (srv, other):
159
+ s.daemon_threads = True
160
+ threading.Thread(target=s.serve_forever, daemon=True).start()
161
+ live, dead = f"http://127.0.0.1:{srv.server_address[1]}/v1", f"http://127.0.0.1:{free_port()}/v1"
162
+ OTHER = f"http://localhost:{other.server_address[1]}/v1"
163
+
164
+ tmp = pathlib.Path(tempfile.mkdtemp(prefix="bench-client-"))
165
+ scenarios = ["ok", "short", "empty", "notfound", "ratelimit", "flaky", "html502", "slow", "notice",
166
+ "refused", "nokey"]
167
+ own = ["redirect", "trickle", "cutoff", "nochoices", "paced"] # the built-in client only
168
+ # The endpoint the live scenarios are on, so they share a connection ("fake")
169
+ # and pacing has one to limit: one call a minute for the paced model.
170
+ (DATA / "config").mkdir(parents=True, exist_ok=True)
171
+ (DATA / "config" / "endpoints.json").write_text(json.dumps(
172
+ {"fake": {"base": live, "key": "fake", "kind": "provider", "default": True}}), encoding="utf-8")
173
+ (DATA / "config" / "model-overrides.json").write_text('{"t-paced": {"limits": {"rpm": 1}}}',
174
+ encoding="utf-8")
175
+ yaml = []
176
+ for s in scenarios + own:
177
+ yaml += [f"- model_id: t-{s}", f" model_name: {'ok' if s in ('refused', 'nokey') else s}",
178
+ f' api_base: "{dead if s == "refused" else live}"',
179
+ f" api_key_name: {'unset' if s == 'nokey' else 'fake'}"]
180
+ if s == "ok":
181
+ yaml += [" headers:", ' X-Title: "bench test"']
182
+ (tmp / "extra-openai-models.yaml").write_text("\n".join(yaml) + "\n", encoding="utf-8")
183
+ (tmp / "keys.json").write_text('{"fake": "sk-test-not-a-key"}', encoding="utf-8")
184
+ (tmp / "model_options.json").write_text('{"t-ok": {"max_tokens": "123"}}', encoding="utf-8")
185
+ (tmp / "options.json").write_text('{"t-ok": {"max_tokens": 123}}', encoding="utf-8")
186
+ os.environ["LLM_USER_PATH"] = str(tmp) # llm reads only the temp folder
187
+ endpoints.YAML, endpoints.LLM_DIR, client.OPTIONS = (tmp / "extra-openai-models.yaml", tmp,
188
+ tmp / "options.json")
189
+
190
+ binary, failures, prompt = run.llm_binary(), 0, "Say something."
191
+
192
+ def kinds(x):
193
+ return ((run.outcome(x) or ("answered",))[0], (run.outcome(x, 1) or ("answered",))[0],
194
+ run.failure_class(x), run.failure_class(x, 1))
195
+ bodies_429 = {}
196
+ for s in scenarios:
197
+ bodies = {}
198
+ for backend in ("llm", "builtin"):
199
+ os.environ["BENCH_CLIENT"] = backend
200
+ # On Windows a refused connection takes about 2 s and the SDK retries:
201
+ # llm needed 19-41 s for "refused" there, past a 30 s deadline.
202
+ bodies[backend], _ = run.ask_once(binary, f"t-{s}", prompt, {"slow": 4, "refused": 90}.get(s, 30))
203
+ a, b = bodies["llm"], bodies["builtin"]
204
+ if s == "ratelimit":
205
+ bodies_429 = bodies
206
+ same = kinds(a) == kinds(b) and (a == b or kinds(a)[1] != "answered")
207
+ print(("ok " if same else "FAIL ") + f"{s:<10} {kinds(b)}")
208
+ if not same:
209
+ print(f" llm: {a[:150]!r}\n builtin: {b[:150]!r}")
210
+ failures += not same
211
+
212
+ # The request itself: same model, messages, options, key and headers.
213
+ sent = {who: seen[(who, "ok")][-1] for who in ("llm", "builtin")}
214
+ want = {k: sent["llm"]["body"].get(k) for k in ("model", "messages", "max_tokens")}
215
+ got = {k: sent["builtin"]["body"].get(k) for k in ("model", "messages", "max_tokens")}
216
+ hdr = [(sent[w]["headers"].get("authorization"), sent[w]["headers"].get("x-title")) for w in sent]
217
+ ok = want == got and want["max_tokens"] == 123 and hdr[0] == hdr[1] == ("Bearer sk-test-not-a-key",
218
+ "bench test")
219
+ print(("ok " if ok else "FAIL ") + f"request matches: {got}, {hdr[1]}"
220
+ + ("" if ok else f"\n llm sent: {want}, {hdr[0]}"))
221
+ failures += not ok
222
+ # Both stream (2b), as llm always has; the built-in client asks for usage in the stream.
223
+ streams = [sent[w]["body"].get("stream") for w in ("llm", "builtin")]
224
+ ok = streams == [True, True] and sent["builtin"]["body"].get("stream_options") == {"include_usage": True}
225
+ print(("ok " if ok else "FAIL ") + f"both ask for a stream (llm {streams[0]}, built-in {streams[1]},"
226
+ f" usage {sent['builtin']['body'].get('stream_options')})")
227
+ failures += not ok
228
+ # Retries as the SDK does them: three tries at a 429 that never clears.
229
+ tries = [len(seen.get((w, "ratelimit"), [])) for w in ("llm", "builtin")]
230
+ ok = tries == [3, 3]
231
+ print(("ok " if ok else "FAIL ") + f"429 tried {tries[1]} times (llm {tries[0]})")
232
+ failures += not ok
233
+
234
+ # Usage from the stream's last chunk, in the result and in the ledger.
235
+ r = client.chat("t-ok", prompt, timeout=30)
236
+ line = [x for x in ledger.rows() if x["alias"] == "t-ok"][-1]
237
+ ok = (r["usage"].get("completion_tokens") == 5 and line["tokens_in"] == 3 and line["tokens_out"] == 5
238
+ and line["status"] == "ok" and line["connection"] == "fake")
239
+ print(("ok " if ok else "FAIL ") + f"usage from the stream and its ledger line: {r['usage']}, {line}")
240
+ failures += not ok
241
+ # Pacing: the model's own rpm 1; the second call in the minute is not sent, and
242
+ # lands where llm's real 429 lands ("ratelimit" above), at both length floors.
243
+ first = client.chat("t-paced", prompt, timeout=30)
244
+ body = run.ask_once(binary, "t-paced", prompt, 30)[0]
245
+ want = kinds(bodies_429["llm"])
246
+ ok = not first["error"] and "not sent" in body and kinds(body) == want \
247
+ and len(seen.get(("builtin", "paced"), [])) == 1
248
+ print(("ok " if ok else "FAIL ") + f"a paced refusal lands where a real 429 does: {kinds(body)}"
249
+ + ("" if ok else f"\n llm's 429: {want}; refusal: {body[:150]!r}"))
250
+ failures += not ok
251
+
252
+ # Where the built-in client is stricter than urllib's defaults would be.
253
+ r = client.chat("t-redirect", prompt, timeout=10)
254
+ ok = r["status"] == 302 and "not followed" in r["error"] and not any(leaked)
255
+ print(("ok " if ok else "FAIL ") + f"redirect refused, key kept: {r['error'][:60]!r}")
256
+ failures += not ok
257
+ t = time.monotonic()
258
+ r = client.chat("t-trickle", prompt, timeout=3)
259
+ ok = r["timed_out"] and time.monotonic() - t < 5
260
+ print(("ok " if ok else "FAIL ") + f"trickling reply cut at the deadline ({time.monotonic() - t:.1f}s)")
261
+ failures += not ok
262
+ r = client.chat("t-cutoff", prompt, timeout=20)
263
+ ok = r["error"].startswith("Error: Error code: 502") and run.failure_class(client.output(r, 20)) == "capacity"
264
+ print(("ok " if ok else "FAIL ") + f"broken-off error body, no crash: {r['error'][:40]!r}")
265
+ failures += not ok
266
+ r = client.chat("t-nochoices", prompt, timeout=10)
267
+ ok = "no choices" in r["error"] and run.failure_class(client.output(r, 10)) == "other"
268
+ print(("ok " if ok else "FAIL ") + "a 200 with no choice is an error, not an empty answer")
269
+ failures += not ok
270
+
271
+ srv.shutdown()
272
+ print("\nthe built-in client lands where llm does" if not failures else f"\n{failures} FAILED")
273
+ return 1 if failures else 0
274
+
275
+
276
+ if __name__ == "__main__":
277
+ sys.exit(main())