@wendellresearch-ai/benchwarmers 0.1.1 → 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 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- 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 -373
- 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 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- 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 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- 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 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/scripts/test_auth.py
CHANGED
|
@@ -1,255 +1,255 @@
|
|
|
1
|
-
"""Login, gateway keys and one port, through the real handlers. No real files.
|
|
2
|
-
|
|
3
|
-
python scripts/test_auth.py
|
|
4
|
-
|
|
5
|
-
metrics/guard.py's self-check covers the pieces (the cookie, key hashing,
|
|
6
|
-
the startup refusal); this covers what the servers do with them, route by
|
|
7
|
-
route: the dashboard behind the login cookie, the gateway's routes behind a
|
|
8
|
-
gateway key on both ports, the open routes (health, login, Connect's
|
|
9
|
-
callback), no auth at all without an admin token, the access log, and both
|
|
10
|
-
servers refusing to listen beyond this machine without a token. Keys, the
|
|
11
|
-
change log and collections point at a temp folder.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import io
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import pathlib
|
|
20
|
-
import subprocess
|
|
21
|
-
import sys
|
|
22
|
-
import tempfile
|
|
23
|
-
import threading
|
|
24
|
-
import urllib.error
|
|
25
|
-
import urllib.request
|
|
26
|
-
from http.server import ThreadingHTTPServer
|
|
27
|
-
|
|
28
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
29
|
-
sys.path.insert(0, str(ROOT))
|
|
30
|
-
import serve # noqa: E402
|
|
31
|
-
import gateway # noqa: E402
|
|
32
|
-
import guard # noqa: E402
|
|
33
|
-
|
|
34
|
-
TOKEN = "test-admin-token-0123456789"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
38
|
-
def redirect_request(self, *args, **kwargs):
|
|
39
|
-
return None
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
OPENER = urllib.request.build_opener(NoRedirect)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def serve_on(handler) -> ThreadingHTTPServer:
|
|
46
|
-
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
47
|
-
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
48
|
-
return server
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def call(port: int, path: str, body: dict | None = None, method: str | None = None,
|
|
52
|
-
**headers) -> tuple[int, dict, str]:
|
|
53
|
-
"""(status, headers, body text) for one request; HTTP errors are answers too."""
|
|
54
|
-
headers = {"Host": f"127.0.0.1:{port}", **headers}
|
|
55
|
-
data = None
|
|
56
|
-
if body is not None:
|
|
57
|
-
data, headers["Content-Type"] = json.dumps(body).encode(), "application/json"
|
|
58
|
-
req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", data, headers, method=method)
|
|
59
|
-
try:
|
|
60
|
-
with OPENER.open(req, timeout=10) as r:
|
|
61
|
-
return r.status, dict(r.headers), r.read().decode()
|
|
62
|
-
except urllib.error.HTTPError as e:
|
|
63
|
-
return e.code, dict(e.headers), e.read().decode()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def refuses_to_start(script: str) -> str:
|
|
67
|
-
"""The startup message when `script` is told to listen beyond this machine without a token."""
|
|
68
|
-
env = {k: v for k, v in os.environ.items() if k != "BENCHWARMERS_ADMIN_TOKEN"}
|
|
69
|
-
env.update(BENCH_BIND="0.0.0.0", BENCH_PORT="8199", BENCH_GATEWAY_PORT="8198", PYTHONIOENCODING="utf-8")
|
|
70
|
-
done = subprocess.run([sys.executable, script], cwd=ROOT, env=env, capture_output=True, text=True,
|
|
71
|
-
encoding="utf-8", errors="replace", timeout=120)
|
|
72
|
-
assert done.returncode != 0, f"{script} started on 0.0.0.0 without a token"
|
|
73
|
-
return done.stderr
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def main() -> int:
|
|
77
|
-
for script in ("serve.py", "gateway.py"):
|
|
78
|
-
assert "BENCHWARMERS_ADMIN_TOKEN" in refuses_to_start(script), script
|
|
79
|
-
|
|
80
|
-
dash, gw = serve_on(serve.Handler), serve_on(gateway.Handler)
|
|
81
|
-
d, g = dash.server_port, gw.server_port
|
|
82
|
-
saved = (guard.KEYS, serve.CHANGES_PATH, serve.PORT, gateway.bench_collections.FILE,
|
|
83
|
-
os.environ.pop("BENCHWARMERS_ADMIN_TOKEN", None), sys.stderr, gateway.run_collection,
|
|
84
|
-
os.environ.pop("BENCHWARMERS_REQUIRE_KEYS", None), os.environ.pop("BENCH_ALLOWED_ORIGINS", None))
|
|
85
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
86
|
-
t = pathlib.Path(tmp)
|
|
87
|
-
try:
|
|
88
|
-
guard.KEYS, serve.CHANGES_PATH, serve.PORT = t / "gateway-keys.json", t / "changes.jsonl", d
|
|
89
|
-
gateway.bench_collections.FILE = t / "collections.json"
|
|
90
|
-
gateway.bench_collections.FILE.write_text(json.dumps({"core": {"members": [], "description": "x"},
|
|
91
|
-
"vision": {"members": [], "description": "x"}}),
|
|
92
|
-
encoding="utf-8")
|
|
93
|
-
# The panel is test_merged's and test_vision's; here only who asked, and whether it ran.
|
|
94
|
-
ran = []
|
|
95
|
-
gateway.run_collection = lambda name, messages, nominated, reply, who: (
|
|
96
|
-
ran.append((name, who)) or "ok", {"seats": []})
|
|
97
|
-
|
|
98
|
-
# NO TOKEN: everything as before -- no login, no key, any bearer ignored.
|
|
99
|
-
assert call(d, "/api/stamp")[0] == 200
|
|
100
|
-
assert call(d, "/login")[:2][0] == 303
|
|
101
|
-
for port in (d, g):
|
|
102
|
-
status, _, text = call(port, "/v1/models", Authorization="Bearer sk-anything")
|
|
103
|
-
assert status == 200 and "bench/core" in text, (port, status, text)
|
|
104
|
-
|
|
105
|
-
# TOKEN SET: the dashboard asks for the login cookie...
|
|
106
|
-
os.environ["BENCHWARMERS_ADMIN_TOKEN"] = TOKEN
|
|
107
|
-
status, headers, _ = call(d, "/")
|
|
108
|
-
assert status == 303 and headers["Location"] == "/login", (status, headers)
|
|
109
|
-
assert call(d, "/api/stamp")[0] == 401
|
|
110
|
-
assert call(d, "/api/theme", {"theme": "nope"})[0] == 401 # writes too
|
|
111
|
-
# Not even which routes exist: the Host, then the login, then the 404.
|
|
112
|
-
assert call(d, "/api/nope", {}, Host="evil.example")[0] == 403
|
|
113
|
-
assert call(d, "/api/nope", {})[0] == 401
|
|
114
|
-
assert call(d, "/fonts/chivo-latin-400-normal.woff2")[0] == 303
|
|
115
|
-
status, _, page = call(d, "/login")
|
|
116
|
-
assert status == 200 and "Admin token" in page
|
|
117
|
-
# ...which only the admin token gets, through the same guard as any write.
|
|
118
|
-
assert call(d, "/login", {"token": "wrong"})[0] == 401
|
|
119
|
-
assert call(d, "/login", {"token": TOKEN}, Origin="http://evil.example")[0] == 403
|
|
120
|
-
status, headers, _ = call(d, "/login", {"token": TOKEN})
|
|
121
|
-
cookie = headers.get("Set-Cookie", "")
|
|
122
|
-
assert status == 200 and "HttpOnly" in cookie and "SameSite=Strict" in cookie, headers
|
|
123
|
-
assert TOKEN not in cookie
|
|
124
|
-
jar = {"Cookie": cookie.split(";", 1)[0]}
|
|
125
|
-
assert call(d, "/api/stamp", **jar)[0] == 200
|
|
126
|
-
assert call(d, "/api/theme", {"theme": "nope"}, **jar)[0] == 400 # in, then the theme rule
|
|
127
|
-
assert call(d, "/api/nope", {}, **jar)[0] == 404
|
|
128
|
-
|
|
129
|
-
# Open without either: health (any Host), and Connect's callback.
|
|
130
|
-
assert call(d, "/health", Host="platform.internal:9")[:3:2] == (200, '{"ok": true}')
|
|
131
|
-
status, _, page = call(d, "/connect/callback?state=nope&code=x")
|
|
132
|
-
assert status == 200 and "unknown or has expired" in page, page
|
|
133
|
-
# ...but not a path merely beginning like it.
|
|
134
|
-
assert call(d, "/connect/callbackx")[0] == 303
|
|
135
|
-
|
|
136
|
-
# Gateway keys, made from the dashboard and shown once.
|
|
137
|
-
assert call(d, "/api/gateway-key", {"name": "chat-ui"})[0] == 401
|
|
138
|
-
status, _, text = call(d, "/api/gateway-key", {"name": "chat-ui"}, **jar)
|
|
139
|
-
key = json.loads(text)["key"]
|
|
140
|
-
assert status == 200 and key not in guard.KEYS.read_text(encoding="utf-8")
|
|
141
|
-
assert key not in serve.CHANGES_PATH.read_text(encoding="utf-8")
|
|
142
|
-
assert key not in serve.settings_page() and "chat-ui" in serve.settings_page()
|
|
143
|
-
bearer = {"Authorization": f"Bearer {key}"}
|
|
144
|
-
|
|
145
|
-
# The gateway's routes, on both ports: the key, never the cookie.
|
|
146
|
-
for port in (d, g):
|
|
147
|
-
status, headers, _ = call(port, "/v1/models")
|
|
148
|
-
assert status == 401 and headers["WWW-Authenticate"].startswith("Bearer"), (port, headers)
|
|
149
|
-
assert call(port, "/v1/models", **jar)[0] == 401, port
|
|
150
|
-
assert call(port, "/v1/models", Authorization="Bearer sk-dummy")[0] == 401, port
|
|
151
|
-
status, _, text = call(port, "/v1/models", **bearer)
|
|
152
|
-
assert status == 200 and json.loads(text)["data"][0]["id"] == "bench/core", (port, text)
|
|
153
|
-
# One port: /bench/verdict reaches the gateway's own validation.
|
|
154
|
-
status, _, text = call(port, "/bench/verdict", {"call": "x"}, **bearer)
|
|
155
|
-
assert status == 400 and "useful" in json.loads(text)["error"]["message"], (port, text)
|
|
156
|
-
assert call(port, "/v1/models", Host="evil.example", **bearer)[0] == 403, port # Host still checked
|
|
157
|
-
# A key never opens the dashboard.
|
|
158
|
-
assert call(d, "/api/stamp", **bearer)[0] == 401
|
|
159
|
-
assert call(d, "/", **bearer)[0] == 303
|
|
160
|
-
|
|
161
|
-
# The access log: route, status, who; never a key, a query or a callback's code.
|
|
162
|
-
log = io.StringIO()
|
|
163
|
-
sys.stderr = log
|
|
164
|
-
call(d, "/v1/models", **bearer)
|
|
165
|
-
call(d, "/api/stamp", **jar)
|
|
166
|
-
call(d, "/connect/callback?state=nope&code=secret-code")
|
|
167
|
-
call(d, "/connect/callback/sid-in-path?code=secret-code")
|
|
168
|
-
sys.stderr = saved[5]
|
|
169
|
-
lines = log.getvalue()
|
|
170
|
-
assert "GET /v1/models 200 chat-ui" in lines and "GET /api/stamp 200 admin" in lines, lines
|
|
171
|
-
assert key not in lines and "secret-code" not in lines and "sid-in-path" not in lines, lines
|
|
172
|
-
|
|
173
|
-
# A key limited to collections and a rate, made on Settings: it sees and calls only
|
|
174
|
-
# those, its name rides on the call, and past its cap it waits.
|
|
175
|
-
status, _, text = call(d, "/api/gateway-key", {"name": "estate", "collections": ["vision"], "rpm": "1"},
|
|
176
|
-
**jar)
|
|
177
|
-
assert status == 200, text
|
|
178
|
-
estate = {"Authorization": f"Bearer {json.loads(text)['key']}"}
|
|
179
|
-
assert call(d, "/api/gateway-key", {"name": "other", "collections": ["nope"]}, **jar)[0] == 400
|
|
180
|
-
assert call(d, "/api/gateway-key", {"name": "other", "rpm": "lots"}, **jar)[0] == 400
|
|
181
|
-
status, _, text = call(g, "/v1/models", **estate)
|
|
182
|
-
assert [m["id"] for m in json.loads(text)["data"]] == ["bench/vision", "bench/vision:merged"], text
|
|
183
|
-
ask = {"messages": [{"role": "user", "content": "hi"}]}
|
|
184
|
-
status, _, text = call(g, "/v1/chat/completions", {"model": "bench/core", **ask}, **estate)
|
|
185
|
-
assert status == 403 and "may call bench/vision" in text, text
|
|
186
|
-
assert call(g, "/v1/chat/completions", {"model": "bench/vision", **ask}, **estate)[0] == 200
|
|
187
|
-
assert call(g, "/v1/chat/completions", {"model": "bench/core", **ask}, **bearer)[0] == 200
|
|
188
|
-
assert ran == [("vision", "estate"), ("core", "chat-ui")], ran
|
|
189
|
-
status, headers, _ = call(g, "/v1/chat/completions", {"model": "bench/vision", **ask}, **estate)
|
|
190
|
-
assert status == 429 and int(headers["Retry-After"]) >= 1, (status, headers)
|
|
191
|
-
page = serve.settings_page()
|
|
192
|
-
assert "estate" in page and "bwk_…" in page and ">vision<" in page, page
|
|
193
|
-
|
|
194
|
-
# CORS for a browser app on an allowed origin, on the gateway's routes only.
|
|
195
|
-
os.environ["BENCH_ALLOWED_ORIGINS"] = "https://estate.example"
|
|
196
|
-
app, evil = {"Origin": "https://estate.example"}, {"Origin": "https://evil.example"}
|
|
197
|
-
asks = {"Access-Control-Request-Method": "POST",
|
|
198
|
-
"Access-Control-Request-Headers": "authorization, content-type, x-stainless-os"}
|
|
199
|
-
for port in (d, g):
|
|
200
|
-
status, headers, _ = call(port, "/v1/chat/completions", method="OPTIONS", **app, **asks)
|
|
201
|
-
assert status == 204 and headers["Access-Control-Allow-Origin"] == "https://estate.example", headers
|
|
202
|
-
assert "Authorization" in headers["Access-Control-Allow-Headers"] and headers["Vary"] == "Origin"
|
|
203
|
-
assert "x-stainless-os" in headers["Access-Control-Allow-Headers"] # the OpenAI JS SDK's
|
|
204
|
-
assert "Access-Control-Allow-Credentials" not in headers
|
|
205
|
-
status, headers, _ = call(port, "/v1/models", method="OPTIONS", **app,
|
|
206
|
-
**{"Access-Control-Request-Headers": "x-ok; not a name"})
|
|
207
|
-
assert status == 204 and headers["Access-Control-Allow-Headers"] == "Authorization, Content-Type"
|
|
208
|
-
status, headers, _ = call(port, "/v1/chat/completions", method="OPTIONS", **evil, **asks)
|
|
209
|
-
assert status == 403 and "Access-Control-Allow-Origin" not in headers, (port, headers)
|
|
210
|
-
status, headers, _ = call(port, "/v1/models", **bearer, **app)
|
|
211
|
-
assert status == 200 and headers["Access-Control-Allow-Origin"] == "https://estate.example"
|
|
212
|
-
assert "Access-Control-Allow-Origin" not in call(port, "/v1/models", **bearer, **evil)[1]
|
|
213
|
-
# The app reads a refusal too; cross-site is fine for it, for nobody else.
|
|
214
|
-
assert call(port, "/v1/models", **app)[1]["Access-Control-Allow-Origin"] == "https://estate.example"
|
|
215
|
-
status, _, text = call(port, "/bench/verdict", {"call": "x"}, **bearer, **app,
|
|
216
|
-
**{"Sec-Fetch-Site": "cross-site"})
|
|
217
|
-
assert status == 400 and "useful" in text, (port, text)
|
|
218
|
-
assert call(port, "/bench/verdict", {"call": "x"}, **bearer, **evil,
|
|
219
|
-
**{"Sec-Fetch-Site": "cross-site"})[0] == 403
|
|
220
|
-
# The dashboard: no preflight, no CORS, same-origin as before.
|
|
221
|
-
assert call(d, "/api/stamp", method="OPTIONS", **app, **asks)[0] == 501
|
|
222
|
-
status, headers, _ = call(d, "/api/theme", {"theme": "nope"}, **jar, **app, **{"Sec-Fetch-Site": "cross-site"})
|
|
223
|
-
assert status == 403 and "Access-Control-Allow-Origin" not in headers, headers
|
|
224
|
-
assert "Access-Control-Allow-Origin" not in call(d, "/api/stamp", **jar, **app)[1]
|
|
225
|
-
del os.environ["BENCH_ALLOWED_ORIGINS"]
|
|
226
|
-
|
|
227
|
-
# Revoked: refused at once.
|
|
228
|
-
assert call(d, "/api/gateway-key/remove", {"name": "chat-ui"}, **jar)[0] == 200
|
|
229
|
-
assert call(d, "/v1/models", **bearer)[0] == 401
|
|
230
|
-
assert call(d, "/api/gateway-key/remove", {"name": "chat-ui"}, **jar)[0] == 400
|
|
231
|
-
|
|
232
|
-
# No admin token, keys required on this machine: the gateway asks, the dashboard does not.
|
|
233
|
-
del os.environ["BENCHWARMERS_ADMIN_TOKEN"]
|
|
234
|
-
os.environ["BENCHWARMERS_REQUIRE_KEYS"] = "1"
|
|
235
|
-
for port in (d, g):
|
|
236
|
-
assert call(port, "/v1/models", Authorization="Bearer sk-anything")[0] == 401, port
|
|
237
|
-
assert call(port, "/v1/models", **estate)[0] == 200, port
|
|
238
|
-
assert call(d, "/api/stamp")[0] == 200
|
|
239
|
-
finally:
|
|
240
|
-
(guard.KEYS, serve.CHANGES_PATH, serve.PORT, gateway.bench_collections.FILE) = saved[:4]
|
|
241
|
-
gateway.run_collection = saved[6]
|
|
242
|
-
for name, value in (("BENCHWARMERS_ADMIN_TOKEN", saved[4]), ("BENCHWARMERS_REQUIRE_KEYS", saved[7]),
|
|
243
|
-
("BENCH_ALLOWED_ORIGINS", saved[8])):
|
|
244
|
-
os.environ.pop(name, None)
|
|
245
|
-
if value is not None:
|
|
246
|
-
os.environ[name] = value
|
|
247
|
-
sys.stderr = saved[5]
|
|
248
|
-
dash.shutdown()
|
|
249
|
-
gw.shutdown()
|
|
250
|
-
print("ok")
|
|
251
|
-
return 0
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if __name__ == "__main__":
|
|
255
|
-
sys.exit(main())
|
|
1
|
+
"""Login, gateway keys and one port, through the real handlers. No real files.
|
|
2
|
+
|
|
3
|
+
python scripts/test_auth.py
|
|
4
|
+
|
|
5
|
+
metrics/guard.py's self-check covers the pieces (the cookie, key hashing,
|
|
6
|
+
the startup refusal); this covers what the servers do with them, route by
|
|
7
|
+
route: the dashboard behind the login cookie, the gateway's routes behind a
|
|
8
|
+
gateway key on both ports, the open routes (health, login, Connect's
|
|
9
|
+
callback), no auth at all without an admin token, the access log, and both
|
|
10
|
+
servers refusing to listen beyond this machine without a token. Keys, the
|
|
11
|
+
change log and collections point at a temp folder.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import threading
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from http.server import ThreadingHTTPServer
|
|
27
|
+
|
|
28
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
29
|
+
sys.path.insert(0, str(ROOT))
|
|
30
|
+
import serve # noqa: E402
|
|
31
|
+
import gateway # noqa: E402
|
|
32
|
+
import guard # noqa: E402
|
|
33
|
+
|
|
34
|
+
TOKEN = "test-admin-token-0123456789"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
38
|
+
def redirect_request(self, *args, **kwargs):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
OPENER = urllib.request.build_opener(NoRedirect)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def serve_on(handler) -> ThreadingHTTPServer:
|
|
46
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
47
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
48
|
+
return server
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def call(port: int, path: str, body: dict | None = None, method: str | None = None,
|
|
52
|
+
**headers) -> tuple[int, dict, str]:
|
|
53
|
+
"""(status, headers, body text) for one request; HTTP errors are answers too."""
|
|
54
|
+
headers = {"Host": f"127.0.0.1:{port}", **headers}
|
|
55
|
+
data = None
|
|
56
|
+
if body is not None:
|
|
57
|
+
data, headers["Content-Type"] = json.dumps(body).encode(), "application/json"
|
|
58
|
+
req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", data, headers, method=method)
|
|
59
|
+
try:
|
|
60
|
+
with OPENER.open(req, timeout=10) as r:
|
|
61
|
+
return r.status, dict(r.headers), r.read().decode()
|
|
62
|
+
except urllib.error.HTTPError as e:
|
|
63
|
+
return e.code, dict(e.headers), e.read().decode()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def refuses_to_start(script: str) -> str:
|
|
67
|
+
"""The startup message when `script` is told to listen beyond this machine without a token."""
|
|
68
|
+
env = {k: v for k, v in os.environ.items() if k != "BENCHWARMERS_ADMIN_TOKEN"}
|
|
69
|
+
env.update(BENCH_BIND="0.0.0.0", BENCH_PORT="8199", BENCH_GATEWAY_PORT="8198", PYTHONIOENCODING="utf-8")
|
|
70
|
+
done = subprocess.run([sys.executable, script], cwd=ROOT, env=env, capture_output=True, text=True,
|
|
71
|
+
encoding="utf-8", errors="replace", timeout=120)
|
|
72
|
+
assert done.returncode != 0, f"{script} started on 0.0.0.0 without a token"
|
|
73
|
+
return done.stderr
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> int:
|
|
77
|
+
for script in ("serve.py", "gateway.py"):
|
|
78
|
+
assert "BENCHWARMERS_ADMIN_TOKEN" in refuses_to_start(script), script
|
|
79
|
+
|
|
80
|
+
dash, gw = serve_on(serve.Handler), serve_on(gateway.Handler)
|
|
81
|
+
d, g = dash.server_port, gw.server_port
|
|
82
|
+
saved = (guard.KEYS, serve.CHANGES_PATH, serve.PORT, gateway.bench_collections.FILE,
|
|
83
|
+
os.environ.pop("BENCHWARMERS_ADMIN_TOKEN", None), sys.stderr, gateway.run_collection,
|
|
84
|
+
os.environ.pop("BENCHWARMERS_REQUIRE_KEYS", None), os.environ.pop("BENCH_ALLOWED_ORIGINS", None))
|
|
85
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
86
|
+
t = pathlib.Path(tmp)
|
|
87
|
+
try:
|
|
88
|
+
guard.KEYS, serve.CHANGES_PATH, serve.PORT = t / "gateway-keys.json", t / "changes.jsonl", d
|
|
89
|
+
gateway.bench_collections.FILE = t / "collections.json"
|
|
90
|
+
gateway.bench_collections.FILE.write_text(json.dumps({"core": {"members": [], "description": "x"},
|
|
91
|
+
"vision": {"members": [], "description": "x"}}),
|
|
92
|
+
encoding="utf-8")
|
|
93
|
+
# The panel is test_merged's and test_vision's; here only who asked, and whether it ran.
|
|
94
|
+
ran = []
|
|
95
|
+
gateway.run_collection = lambda name, messages, nominated, reply, who: (
|
|
96
|
+
ran.append((name, who)) or "ok", {"seats": []})
|
|
97
|
+
|
|
98
|
+
# NO TOKEN: everything as before -- no login, no key, any bearer ignored.
|
|
99
|
+
assert call(d, "/api/stamp")[0] == 200
|
|
100
|
+
assert call(d, "/login")[:2][0] == 303
|
|
101
|
+
for port in (d, g):
|
|
102
|
+
status, _, text = call(port, "/v1/models", Authorization="Bearer sk-anything")
|
|
103
|
+
assert status == 200 and "bench/core" in text, (port, status, text)
|
|
104
|
+
|
|
105
|
+
# TOKEN SET: the dashboard asks for the login cookie...
|
|
106
|
+
os.environ["BENCHWARMERS_ADMIN_TOKEN"] = TOKEN
|
|
107
|
+
status, headers, _ = call(d, "/")
|
|
108
|
+
assert status == 303 and headers["Location"] == "/login", (status, headers)
|
|
109
|
+
assert call(d, "/api/stamp")[0] == 401
|
|
110
|
+
assert call(d, "/api/theme", {"theme": "nope"})[0] == 401 # writes too
|
|
111
|
+
# Not even which routes exist: the Host, then the login, then the 404.
|
|
112
|
+
assert call(d, "/api/nope", {}, Host="evil.example")[0] == 403
|
|
113
|
+
assert call(d, "/api/nope", {})[0] == 401
|
|
114
|
+
assert call(d, "/fonts/chivo-latin-400-normal.woff2")[0] == 303
|
|
115
|
+
status, _, page = call(d, "/login")
|
|
116
|
+
assert status == 200 and "Admin token" in page
|
|
117
|
+
# ...which only the admin token gets, through the same guard as any write.
|
|
118
|
+
assert call(d, "/login", {"token": "wrong"})[0] == 401
|
|
119
|
+
assert call(d, "/login", {"token": TOKEN}, Origin="http://evil.example")[0] == 403
|
|
120
|
+
status, headers, _ = call(d, "/login", {"token": TOKEN})
|
|
121
|
+
cookie = headers.get("Set-Cookie", "")
|
|
122
|
+
assert status == 200 and "HttpOnly" in cookie and "SameSite=Strict" in cookie, headers
|
|
123
|
+
assert TOKEN not in cookie
|
|
124
|
+
jar = {"Cookie": cookie.split(";", 1)[0]}
|
|
125
|
+
assert call(d, "/api/stamp", **jar)[0] == 200
|
|
126
|
+
assert call(d, "/api/theme", {"theme": "nope"}, **jar)[0] == 400 # in, then the theme rule
|
|
127
|
+
assert call(d, "/api/nope", {}, **jar)[0] == 404
|
|
128
|
+
|
|
129
|
+
# Open without either: health (any Host), and Connect's callback.
|
|
130
|
+
assert call(d, "/health", Host="platform.internal:9")[:3:2] == (200, '{"ok": true}')
|
|
131
|
+
status, _, page = call(d, "/connect/callback?state=nope&code=x")
|
|
132
|
+
assert status == 200 and "unknown or has expired" in page, page
|
|
133
|
+
# ...but not a path merely beginning like it.
|
|
134
|
+
assert call(d, "/connect/callbackx")[0] == 303
|
|
135
|
+
|
|
136
|
+
# Gateway keys, made from the dashboard and shown once.
|
|
137
|
+
assert call(d, "/api/gateway-key", {"name": "chat-ui"})[0] == 401
|
|
138
|
+
status, _, text = call(d, "/api/gateway-key", {"name": "chat-ui"}, **jar)
|
|
139
|
+
key = json.loads(text)["key"]
|
|
140
|
+
assert status == 200 and key not in guard.KEYS.read_text(encoding="utf-8")
|
|
141
|
+
assert key not in serve.CHANGES_PATH.read_text(encoding="utf-8")
|
|
142
|
+
assert key not in serve.settings_page() and "chat-ui" in serve.settings_page()
|
|
143
|
+
bearer = {"Authorization": f"Bearer {key}"}
|
|
144
|
+
|
|
145
|
+
# The gateway's routes, on both ports: the key, never the cookie.
|
|
146
|
+
for port in (d, g):
|
|
147
|
+
status, headers, _ = call(port, "/v1/models")
|
|
148
|
+
assert status == 401 and headers["WWW-Authenticate"].startswith("Bearer"), (port, headers)
|
|
149
|
+
assert call(port, "/v1/models", **jar)[0] == 401, port
|
|
150
|
+
assert call(port, "/v1/models", Authorization="Bearer sk-dummy")[0] == 401, port
|
|
151
|
+
status, _, text = call(port, "/v1/models", **bearer)
|
|
152
|
+
assert status == 200 and json.loads(text)["data"][0]["id"] == "bench/core", (port, text)
|
|
153
|
+
# One port: /bench/verdict reaches the gateway's own validation.
|
|
154
|
+
status, _, text = call(port, "/bench/verdict", {"call": "x"}, **bearer)
|
|
155
|
+
assert status == 400 and "useful" in json.loads(text)["error"]["message"], (port, text)
|
|
156
|
+
assert call(port, "/v1/models", Host="evil.example", **bearer)[0] == 403, port # Host still checked
|
|
157
|
+
# A key never opens the dashboard.
|
|
158
|
+
assert call(d, "/api/stamp", **bearer)[0] == 401
|
|
159
|
+
assert call(d, "/", **bearer)[0] == 303
|
|
160
|
+
|
|
161
|
+
# The access log: route, status, who; never a key, a query or a callback's code.
|
|
162
|
+
log = io.StringIO()
|
|
163
|
+
sys.stderr = log
|
|
164
|
+
call(d, "/v1/models", **bearer)
|
|
165
|
+
call(d, "/api/stamp", **jar)
|
|
166
|
+
call(d, "/connect/callback?state=nope&code=secret-code")
|
|
167
|
+
call(d, "/connect/callback/sid-in-path?code=secret-code")
|
|
168
|
+
sys.stderr = saved[5]
|
|
169
|
+
lines = log.getvalue()
|
|
170
|
+
assert "GET /v1/models 200 chat-ui" in lines and "GET /api/stamp 200 admin" in lines, lines
|
|
171
|
+
assert key not in lines and "secret-code" not in lines and "sid-in-path" not in lines, lines
|
|
172
|
+
|
|
173
|
+
# A key limited to collections and a rate, made on Settings: it sees and calls only
|
|
174
|
+
# those, its name rides on the call, and past its cap it waits.
|
|
175
|
+
status, _, text = call(d, "/api/gateway-key", {"name": "estate", "collections": ["vision"], "rpm": "1"},
|
|
176
|
+
**jar)
|
|
177
|
+
assert status == 200, text
|
|
178
|
+
estate = {"Authorization": f"Bearer {json.loads(text)['key']}"}
|
|
179
|
+
assert call(d, "/api/gateway-key", {"name": "other", "collections": ["nope"]}, **jar)[0] == 400
|
|
180
|
+
assert call(d, "/api/gateway-key", {"name": "other", "rpm": "lots"}, **jar)[0] == 400
|
|
181
|
+
status, _, text = call(g, "/v1/models", **estate)
|
|
182
|
+
assert [m["id"] for m in json.loads(text)["data"]] == ["bench/vision", "bench/vision:merged"], text
|
|
183
|
+
ask = {"messages": [{"role": "user", "content": "hi"}]}
|
|
184
|
+
status, _, text = call(g, "/v1/chat/completions", {"model": "bench/core", **ask}, **estate)
|
|
185
|
+
assert status == 403 and "may call bench/vision" in text, text
|
|
186
|
+
assert call(g, "/v1/chat/completions", {"model": "bench/vision", **ask}, **estate)[0] == 200
|
|
187
|
+
assert call(g, "/v1/chat/completions", {"model": "bench/core", **ask}, **bearer)[0] == 200
|
|
188
|
+
assert ran == [("vision", "estate"), ("core", "chat-ui")], ran
|
|
189
|
+
status, headers, _ = call(g, "/v1/chat/completions", {"model": "bench/vision", **ask}, **estate)
|
|
190
|
+
assert status == 429 and int(headers["Retry-After"]) >= 1, (status, headers)
|
|
191
|
+
page = serve.settings_page()
|
|
192
|
+
assert "estate" in page and "bwk_…" in page and ">vision<" in page, page
|
|
193
|
+
|
|
194
|
+
# CORS for a browser app on an allowed origin, on the gateway's routes only.
|
|
195
|
+
os.environ["BENCH_ALLOWED_ORIGINS"] = "https://estate.example"
|
|
196
|
+
app, evil = {"Origin": "https://estate.example"}, {"Origin": "https://evil.example"}
|
|
197
|
+
asks = {"Access-Control-Request-Method": "POST",
|
|
198
|
+
"Access-Control-Request-Headers": "authorization, content-type, x-stainless-os"}
|
|
199
|
+
for port in (d, g):
|
|
200
|
+
status, headers, _ = call(port, "/v1/chat/completions", method="OPTIONS", **app, **asks)
|
|
201
|
+
assert status == 204 and headers["Access-Control-Allow-Origin"] == "https://estate.example", headers
|
|
202
|
+
assert "Authorization" in headers["Access-Control-Allow-Headers"] and headers["Vary"] == "Origin"
|
|
203
|
+
assert "x-stainless-os" in headers["Access-Control-Allow-Headers"] # the OpenAI JS SDK's
|
|
204
|
+
assert "Access-Control-Allow-Credentials" not in headers
|
|
205
|
+
status, headers, _ = call(port, "/v1/models", method="OPTIONS", **app,
|
|
206
|
+
**{"Access-Control-Request-Headers": "x-ok; not a name"})
|
|
207
|
+
assert status == 204 and headers["Access-Control-Allow-Headers"] == "Authorization, Content-Type"
|
|
208
|
+
status, headers, _ = call(port, "/v1/chat/completions", method="OPTIONS", **evil, **asks)
|
|
209
|
+
assert status == 403 and "Access-Control-Allow-Origin" not in headers, (port, headers)
|
|
210
|
+
status, headers, _ = call(port, "/v1/models", **bearer, **app)
|
|
211
|
+
assert status == 200 and headers["Access-Control-Allow-Origin"] == "https://estate.example"
|
|
212
|
+
assert "Access-Control-Allow-Origin" not in call(port, "/v1/models", **bearer, **evil)[1]
|
|
213
|
+
# The app reads a refusal too; cross-site is fine for it, for nobody else.
|
|
214
|
+
assert call(port, "/v1/models", **app)[1]["Access-Control-Allow-Origin"] == "https://estate.example"
|
|
215
|
+
status, _, text = call(port, "/bench/verdict", {"call": "x"}, **bearer, **app,
|
|
216
|
+
**{"Sec-Fetch-Site": "cross-site"})
|
|
217
|
+
assert status == 400 and "useful" in text, (port, text)
|
|
218
|
+
assert call(port, "/bench/verdict", {"call": "x"}, **bearer, **evil,
|
|
219
|
+
**{"Sec-Fetch-Site": "cross-site"})[0] == 403
|
|
220
|
+
# The dashboard: no preflight, no CORS, same-origin as before.
|
|
221
|
+
assert call(d, "/api/stamp", method="OPTIONS", **app, **asks)[0] == 501
|
|
222
|
+
status, headers, _ = call(d, "/api/theme", {"theme": "nope"}, **jar, **app, **{"Sec-Fetch-Site": "cross-site"})
|
|
223
|
+
assert status == 403 and "Access-Control-Allow-Origin" not in headers, headers
|
|
224
|
+
assert "Access-Control-Allow-Origin" not in call(d, "/api/stamp", **jar, **app)[1]
|
|
225
|
+
del os.environ["BENCH_ALLOWED_ORIGINS"]
|
|
226
|
+
|
|
227
|
+
# Revoked: refused at once.
|
|
228
|
+
assert call(d, "/api/gateway-key/remove", {"name": "chat-ui"}, **jar)[0] == 200
|
|
229
|
+
assert call(d, "/v1/models", **bearer)[0] == 401
|
|
230
|
+
assert call(d, "/api/gateway-key/remove", {"name": "chat-ui"}, **jar)[0] == 400
|
|
231
|
+
|
|
232
|
+
# No admin token, keys required on this machine: the gateway asks, the dashboard does not.
|
|
233
|
+
del os.environ["BENCHWARMERS_ADMIN_TOKEN"]
|
|
234
|
+
os.environ["BENCHWARMERS_REQUIRE_KEYS"] = "1"
|
|
235
|
+
for port in (d, g):
|
|
236
|
+
assert call(port, "/v1/models", Authorization="Bearer sk-anything")[0] == 401, port
|
|
237
|
+
assert call(port, "/v1/models", **estate)[0] == 200, port
|
|
238
|
+
assert call(d, "/api/stamp")[0] == 200
|
|
239
|
+
finally:
|
|
240
|
+
(guard.KEYS, serve.CHANGES_PATH, serve.PORT, gateway.bench_collections.FILE) = saved[:4]
|
|
241
|
+
gateway.run_collection = saved[6]
|
|
242
|
+
for name, value in (("BENCHWARMERS_ADMIN_TOKEN", saved[4]), ("BENCHWARMERS_REQUIRE_KEYS", saved[7]),
|
|
243
|
+
("BENCH_ALLOWED_ORIGINS", saved[8])):
|
|
244
|
+
os.environ.pop(name, None)
|
|
245
|
+
if value is not None:
|
|
246
|
+
os.environ[name] = value
|
|
247
|
+
sys.stderr = saved[5]
|
|
248
|
+
dash.shutdown()
|
|
249
|
+
gw.shutdown()
|
|
250
|
+
print("ok")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
sys.exit(main())
|