@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1

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