@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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -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 +295 -295
- package/metrics/client.py +394 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -65
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -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 +401 -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 +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- 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 +214 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -438
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -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 +1756 -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,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())
|