@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.
- package/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/scripts/test_client.py
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
import
|
|
28
|
-
|
|
29
|
-
import
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
sys
|
|
34
|
-
import
|
|
35
|
-
import
|
|
36
|
-
import
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
self.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
93
|
-
if model == "
|
|
94
|
-
self.send_response(
|
|
95
|
-
self.send_header("Content-
|
|
96
|
-
self.
|
|
97
|
-
self.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
103
|
-
if model == "
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if model == "
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
|
|
133
|
-
"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
ok
|
|
220
|
-
|
|
221
|
-
failures += not ok
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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, 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())
|