@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_vision.py
CHANGED
|
@@ -1,228 +1,228 @@
|
|
|
1
|
-
"""Images through the real gateway handler and client, against a fake upstream.
|
|
2
|
-
|
|
3
|
-
python scripts/test_vision.py
|
|
4
|
-
|
|
5
|
-
A request carrying image parts reaches every seat that can see with its
|
|
6
|
-
messages intact, through the built-in client even with BENCH_CLIENT=llm; a
|
|
7
|
-
text request still takes the llm path. Members that cannot see, or that the
|
|
8
|
-
catalog knows nothing about, are skipped and named; explorers are drawn only
|
|
9
|
-
from models that take images; with no seat that can see, 400; images count in
|
|
10
|
-
the context check, and a body over the cap is a 413 that says what to do.
|
|
11
|
-
No image bytes reach any file in the data folder, the access log, an error
|
|
12
|
-
or the merger, which gets the request as text with placeholders. Temp data
|
|
13
|
-
folder and llm user folder; the `llm` CLI is stubbed, never run.
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import http.client
|
|
19
|
-
import io
|
|
20
|
-
import json
|
|
21
|
-
import os
|
|
22
|
-
import pathlib
|
|
23
|
-
import shutil
|
|
24
|
-
import sys
|
|
25
|
-
import tempfile
|
|
26
|
-
import threading
|
|
27
|
-
import urllib.error
|
|
28
|
-
import urllib.request
|
|
29
|
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
30
|
-
|
|
31
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
32
|
-
TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-vision-"))
|
|
33
|
-
(TMP / "config").mkdir()
|
|
34
|
-
(TMP / "llm").mkdir()
|
|
35
|
-
os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="llm",
|
|
36
|
-
BENCHWARMERS_KEY_FAKE="sk-test")
|
|
37
|
-
os.environ.pop("BENCHWARMERS_ADMIN_TOKEN", None)
|
|
38
|
-
os.environ.pop("BENCHWARMERS_REQUIRE_KEYS", None)
|
|
39
|
-
sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
|
|
40
|
-
|
|
41
|
-
# A 1x1 PNG, generated: the bytes that must never be written down.
|
|
42
|
-
PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
|
43
|
-
DATA_URL = "data:image/png;base64," + PIXEL
|
|
44
|
-
PHOTO = "https://user:pw@photos.example/chair.jpg?sig=SECRET123"
|
|
45
|
-
ASKED: list[dict] = [] # every chat request the fake upstream received
|
|
46
|
-
LLM_CALLS: list[tuple] = [] # every seat that took the llm path (stubbed)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class Upstream(BaseHTTPRequestHandler):
|
|
50
|
-
def do_GET(self): # noqa: N802
|
|
51
|
-
self._send({"data": [{"id": i} for i in ("eye", "eye2", "blind", "unknown", "echo", "tiny", "merger")]})
|
|
52
|
-
|
|
53
|
-
def do_POST(self): # noqa: N802
|
|
54
|
-
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
55
|
-
ASKED.append(body)
|
|
56
|
-
if body["model"] == "echo": # an upstream that echoes the request in its error
|
|
57
|
-
return self._send({"error": {"message": f"bad request: {json.dumps(body['messages'])}"}}, 400)
|
|
58
|
-
saw = any(isinstance(m.get("content"), list) and any(p.get("type") == "image_url" for p in m["content"])
|
|
59
|
-
for m in body["messages"])
|
|
60
|
-
self._send({"model": body["model"], "choices": [{"message": {
|
|
61
|
-
"content": f"answer from {body['model']}" + (" (saw the images)" if saw else "")}}]})
|
|
62
|
-
|
|
63
|
-
def _send(self, data, status=200):
|
|
64
|
-
raw = json.dumps(data).encode()
|
|
65
|
-
self.send_response(status)
|
|
66
|
-
self.send_header("Content-Type", "application/json")
|
|
67
|
-
self.send_header("Content-Length", str(len(raw)))
|
|
68
|
-
self.end_headers()
|
|
69
|
-
self.wfile.write(raw)
|
|
70
|
-
|
|
71
|
-
def log_message(self, *_a):
|
|
72
|
-
pass
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def serve_on(handler) -> ThreadingHTTPServer:
|
|
76
|
-
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
77
|
-
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
78
|
-
return server
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def write(name: str, data) -> None:
|
|
82
|
-
(TMP / "config" / name).write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8")
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def main() -> int:
|
|
86
|
-
upstream = serve_on(Upstream)
|
|
87
|
-
base = f"http://127.0.0.1:{upstream.server_port}"
|
|
88
|
-
write("endpoints.json", {"fake": {"base": base + "/v1", "key": "fake", "default": True, "kind": "provider"},
|
|
89
|
-
"plan": {"base": base + "/plan/v1", "key": "fake", "kind": "provider",
|
|
90
|
-
"api": "responses"}})
|
|
91
|
-
import endpoints
|
|
92
|
-
write("extra-openai-models.yaml", "".join(endpoints.yaml_block(a, s) for a, s in
|
|
93
|
-
(("eye", "eye"), ("eye2", "eye2"), ("blind", "blind"),
|
|
94
|
-
("unknown", "unknown"), ("echo", "echo"), ("tiny", "tiny"),
|
|
95
|
-
("mg", "merger")))
|
|
96
|
-
+ endpoints.yaml_block("planned", "planned", "plan"))
|
|
97
|
-
write("model-overrides.json", {"eye": {"vision": True}, "eye2": {"vision": True}, "echo": {"vision": True},
|
|
98
|
-
"blind": {"vision": False}, "planned": {"vision": True},
|
|
99
|
-
"tiny": {"vision": True, "context_window": 3000}})
|
|
100
|
-
write("gateway.json", {"max_calls": 5, "max_explorers": 2})
|
|
101
|
-
write("collections.json", {
|
|
102
|
-
"vision": {"members": ["eye", "blind", "unknown", "echo"], "description": "photos"},
|
|
103
|
-
"merged": {"members": ["eye", "eye2"], "reply": "merged", "merger": "mg", "description": "x"},
|
|
104
|
-
"blindonly": {"members": ["blind", "unknown", "planned"], "description": "x"},
|
|
105
|
-
"small": {"members": ["tiny"], "description": "x"},
|
|
106
|
-
"text": {"members": ["eye"], "description": "x"}})
|
|
107
|
-
|
|
108
|
-
import client
|
|
109
|
-
import gateway
|
|
110
|
-
import serve
|
|
111
|
-
gateway._ask_llm = lambda cmd, prompt: (LLM_CALLS.append((cmd[2], prompt)) or f"via llm: {cmd[2]}", "")
|
|
112
|
-
gw = serve_on(gateway.Handler)
|
|
113
|
-
url = f"http://127.0.0.1:{gw.server_port}"
|
|
114
|
-
|
|
115
|
-
def post(body: dict) -> tuple[int, dict]:
|
|
116
|
-
req = urllib.request.Request(url + "/v1/chat/completions", json.dumps(body).encode(),
|
|
117
|
-
{"Content-Type": "application/json"})
|
|
118
|
-
try:
|
|
119
|
-
with urllib.request.urlopen(req, timeout=60) as r:
|
|
120
|
-
return r.status, json.loads(r.read())
|
|
121
|
-
except urllib.error.HTTPError as e:
|
|
122
|
-
return e.code, json.loads(e.read())
|
|
123
|
-
|
|
124
|
-
question = [{"role": "system", "content": "You price estate-sale items."},
|
|
125
|
-
{"role": "user", "content": [{"type": "text", "text": "What is this chair worth?"},
|
|
126
|
-
{"type": "image_url", "image_url": {"url": DATA_URL, "detail": "low"}},
|
|
127
|
-
{"type": "image_url", "image_url": {"url": PHOTO}}]}]
|
|
128
|
-
saved_err = sys.stderr
|
|
129
|
-
try:
|
|
130
|
-
# 1. Images reach every seat that can see, intact, past BENCH_CLIENT=llm.
|
|
131
|
-
sys.stderr = log = io.StringIO()
|
|
132
|
-
status, r = post({"model": "bench/vision", "messages": question, "with": ["mg"]})
|
|
133
|
-
sys.stderr = saved_err
|
|
134
|
-
assert status == 200, (status, r)
|
|
135
|
-
b, content = r["bench"], r["choices"][0]["message"]["content"]
|
|
136
|
-
sat = {s["alias"] for s in b["seats"]}
|
|
137
|
-
assert {"eye", "echo"} <= sat and not sat & {"blind", "unknown", "mg"}, sat
|
|
138
|
-
explorers = [s["alias"] for s in b["seats"] if s["seat"].startswith("explorer")]
|
|
139
|
-
assert len(explorers) == 1 and explorers[0] in ("eye2", "tiny"), explorers # only models that see
|
|
140
|
-
assert (b["shape"], b["shape_by"]) == ("vision", "the request carries images"), b
|
|
141
|
-
assert {(k["seat"], k["alias"]) for k in b["skipped"]} == {("member", "blind"), ("member", "unknown"),
|
|
142
|
-
("with", "mg")}, b["skipped"]
|
|
143
|
-
why = {k["alias"]: k["why"] for k in b["skipped"]}
|
|
144
|
-
assert "does not take images" in why["blind"] and "Vision / OCR" in why["unknown"], why
|
|
145
|
-
assert not LLM_CALLS, LLM_CALLS
|
|
146
|
-
assert all(q["messages"] == question for q in ASKED), "a seat got other messages than were sent"
|
|
147
|
-
assert {q["model"] for q in ASKED} == sat
|
|
148
|
-
assert next(s for s in b["seats"] if s["alias"] == "eye")["answer"].endswith("(saw the images)")
|
|
149
|
-
echo = next(s for s in b["seats"] if s["alias"] == "echo")
|
|
150
|
-
assert echo["status"] == "error" and "[image: data URL, 1 KB]" in echo["error"], echo
|
|
151
|
-
assert "blind (does not take images" in content, content
|
|
152
|
-
|
|
153
|
-
# Never the bytes, nor the signed URL's signature or password: not in the reply,
|
|
154
|
-
# the access log, any file in the data folder, or the dashboard's card.
|
|
155
|
-
record = json.loads((TMP / "runs" / "gateway" / "calls.jsonl").read_text(encoding="utf-8").splitlines()[-1])
|
|
156
|
-
assert "[image: data URL, 1 KB]" in record["prompt"], record["prompt"]
|
|
157
|
-
assert "[image: https://photos.example/chair.jpg?...]" in record["prompt"], record["prompt"]
|
|
158
|
-
assert record["key"] == "local" and record["skipped"] == b["skipped"]
|
|
159
|
-
card = serve.gateway_card()
|
|
160
|
-
assert "blind –" in card and "not asked: does not take images" in card
|
|
161
|
-
shown = [json.dumps(r), log.getvalue(), card] + [p.read_text(encoding="utf-8", errors="replace")
|
|
162
|
-
for p in TMP.rglob("*") if p.is_file()]
|
|
163
|
-
for secret in (PIXEL[30:60], "SECRET123", "user:pw"):
|
|
164
|
-
where = [text[max(0, text.find(secret) - 300):text.find(secret) + 50] for text in shown if secret in text]
|
|
165
|
-
assert not where, f"{secret!r} was written down: {where}"
|
|
166
|
-
|
|
167
|
-
# 2. A text request takes the llm path, as before, with the same bench keys.
|
|
168
|
-
write("gateway.json", {"max_calls": 5, "max_explorers": 0}) # members only from here
|
|
169
|
-
ASKED.clear()
|
|
170
|
-
status, r = post({"model": "bench/text", "messages": [{"role": "user", "content": "say hi"}]})
|
|
171
|
-
assert status == 200 and LLM_CALLS == [("eye", "say hi")] and not ASKED, (status, LLM_CALLS, ASKED)
|
|
172
|
-
assert set(r["bench"]) == {"panel", "collection", "shape", "shape_by", "seats", "verdict"}, r["bench"]
|
|
173
|
-
|
|
174
|
-
# 3. Merged: the seats see the images; the merger gets text, placeholders and answers.
|
|
175
|
-
LLM_CALLS.clear()
|
|
176
|
-
status, r = post({"model": "bench/merged", "messages": question})
|
|
177
|
-
assert status == 200 and r["bench"]["reply"] == "merged", r
|
|
178
|
-
[(merger, said)] = LLM_CALLS
|
|
179
|
-
assert merger == "mg" and "[image: data URL, 1 KB]" in said and "(saw the images)" in said, said
|
|
180
|
-
assert PIXEL[30:60] not in said and "SECRET123" not in said
|
|
181
|
-
|
|
182
|
-
# 4. No seat can see and no explorer seat to fill: refused, nobody asked.
|
|
183
|
-
ASKED.clear()
|
|
184
|
-
status, r = post({"model": "bench/blindonly", "messages": question})
|
|
185
|
-
message = r["error"]["message"]
|
|
186
|
-
assert status == 400 and "can see images" in message and "blind" in message and "unknown" in message, r
|
|
187
|
-
assert "Responses API" in message and not ASKED, message # a plan model gets no images yet
|
|
188
|
-
assert "Responses API" in client.chat("planned", "", messages=question)["error"]
|
|
189
|
-
|
|
190
|
-
# 5. Images count in the context check: two of them overflow a 3,000-token window.
|
|
191
|
-
status, r = post({"model": "bench/small", "messages": question})
|
|
192
|
-
assert status == 413 and "each image counted" in r["error"]["message"], r
|
|
193
|
-
|
|
194
|
-
# 6. Images no seat could be sent are refused, never dropped.
|
|
195
|
-
for part in ({"type": "image_url", "image_url": {"url": "file:///etc/passwd"}},
|
|
196
|
-
{"type": "image_url", "image_url": {"url": "data:text/plain;base64,aGk="}},
|
|
197
|
-
{"type": "image_url"}, {"type": "input_image", "image_url": DATA_URL}):
|
|
198
|
-
status, r = post({"model": "bench/text", "messages": [{"role": "user", "content": [part]}]})
|
|
199
|
-
assert status == 400, (part, status, r)
|
|
200
|
-
|
|
201
|
-
# 7. Over the body cap: a 413 that says what to do, before any of it is read.
|
|
202
|
-
conn = http.client.HTTPConnection("127.0.0.1", gw.server_port, timeout=10)
|
|
203
|
-
conn.putrequest("POST", "/v1/chat/completions")
|
|
204
|
-
conn.putheader("Content-Type", "application/json")
|
|
205
|
-
conn.putheader("Content-Length", str(gateway.MAX_BODY + 1))
|
|
206
|
-
conn.endheaders()
|
|
207
|
-
resp = conn.getresponse()
|
|
208
|
-
assert resp.status == 413 and "http(s) URLs" in json.loads(resp.read())["error"]["message"]
|
|
209
|
-
conn.close()
|
|
210
|
-
|
|
211
|
-
# 8. The redaction itself: an inline image, a bare base64 run, a URL's secrets in any form.
|
|
212
|
-
assert gateway.redact(f"x {DATA_URL} y") == "x [image: data URL, 1 KB] y"
|
|
213
|
-
assert gateway.redact("cannot fetch 'https://photos.example/chair.jpg?sig=SECRET123'") \
|
|
214
|
-
== "cannot fetch 'https://photos.example/chair.jpg?...'"
|
|
215
|
-
assert gateway.redact("bad http://[::1?x") == "bad [url]"
|
|
216
|
-
assert gateway.redact("A" * 150) == "[base64, 1 KB]" and gateway.redact("short AAAA") == "short AAAA"
|
|
217
|
-
assert gateway.placeholder(PHOTO) == "[image: https://photos.example/chair.jpg?...]"
|
|
218
|
-
finally:
|
|
219
|
-
sys.stderr = saved_err
|
|
220
|
-
upstream.shutdown()
|
|
221
|
-
gw.shutdown()
|
|
222
|
-
shutil.rmtree(TMP, ignore_errors=True)
|
|
223
|
-
print("ok")
|
|
224
|
-
return 0
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if __name__ == "__main__":
|
|
228
|
-
sys.exit(main())
|
|
1
|
+
"""Images through the real gateway handler and client, against a fake upstream.
|
|
2
|
+
|
|
3
|
+
python scripts/test_vision.py
|
|
4
|
+
|
|
5
|
+
A request carrying image parts reaches every seat that can see with its
|
|
6
|
+
messages intact, through the built-in client even with BENCH_CLIENT=llm; a
|
|
7
|
+
text request still takes the llm path. Members that cannot see, or that the
|
|
8
|
+
catalog knows nothing about, are skipped and named; explorers are drawn only
|
|
9
|
+
from models that take images; with no seat that can see, 400; images count in
|
|
10
|
+
the context check, and a body over the cap is a 413 that says what to do.
|
|
11
|
+
No image bytes reach any file in the data folder, the access log, an error
|
|
12
|
+
or the merger, which gets the request as text with placeholders. Temp data
|
|
13
|
+
folder and llm user folder; the `llm` CLI is stubbed, never run.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import http.client
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import threading
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
30
|
+
|
|
31
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
32
|
+
TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-vision-"))
|
|
33
|
+
(TMP / "config").mkdir()
|
|
34
|
+
(TMP / "llm").mkdir()
|
|
35
|
+
os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="llm",
|
|
36
|
+
BENCHWARMERS_KEY_FAKE="sk-test")
|
|
37
|
+
os.environ.pop("BENCHWARMERS_ADMIN_TOKEN", None)
|
|
38
|
+
os.environ.pop("BENCHWARMERS_REQUIRE_KEYS", None)
|
|
39
|
+
sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
|
|
40
|
+
|
|
41
|
+
# A 1x1 PNG, generated: the bytes that must never be written down.
|
|
42
|
+
PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
|
43
|
+
DATA_URL = "data:image/png;base64," + PIXEL
|
|
44
|
+
PHOTO = "https://user:pw@photos.example/chair.jpg?sig=SECRET123"
|
|
45
|
+
ASKED: list[dict] = [] # every chat request the fake upstream received
|
|
46
|
+
LLM_CALLS: list[tuple] = [] # every seat that took the llm path (stubbed)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Upstream(BaseHTTPRequestHandler):
|
|
50
|
+
def do_GET(self): # noqa: N802
|
|
51
|
+
self._send({"data": [{"id": i} for i in ("eye", "eye2", "blind", "unknown", "echo", "tiny", "merger")]})
|
|
52
|
+
|
|
53
|
+
def do_POST(self): # noqa: N802
|
|
54
|
+
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
55
|
+
ASKED.append(body)
|
|
56
|
+
if body["model"] == "echo": # an upstream that echoes the request in its error
|
|
57
|
+
return self._send({"error": {"message": f"bad request: {json.dumps(body['messages'])}"}}, 400)
|
|
58
|
+
saw = any(isinstance(m.get("content"), list) and any(p.get("type") == "image_url" for p in m["content"])
|
|
59
|
+
for m in body["messages"])
|
|
60
|
+
self._send({"model": body["model"], "choices": [{"message": {
|
|
61
|
+
"content": f"answer from {body['model']}" + (" (saw the images)" if saw else "")}}]})
|
|
62
|
+
|
|
63
|
+
def _send(self, data, status=200):
|
|
64
|
+
raw = json.dumps(data).encode()
|
|
65
|
+
self.send_response(status)
|
|
66
|
+
self.send_header("Content-Type", "application/json")
|
|
67
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
68
|
+
self.end_headers()
|
|
69
|
+
self.wfile.write(raw)
|
|
70
|
+
|
|
71
|
+
def log_message(self, *_a):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def serve_on(handler) -> ThreadingHTTPServer:
|
|
76
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
77
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
78
|
+
return server
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write(name: str, data) -> None:
|
|
82
|
+
(TMP / "config" / name).write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> int:
|
|
86
|
+
upstream = serve_on(Upstream)
|
|
87
|
+
base = f"http://127.0.0.1:{upstream.server_port}"
|
|
88
|
+
write("endpoints.json", {"fake": {"base": base + "/v1", "key": "fake", "default": True, "kind": "provider"},
|
|
89
|
+
"plan": {"base": base + "/plan/v1", "key": "fake", "kind": "provider",
|
|
90
|
+
"api": "responses"}})
|
|
91
|
+
import endpoints
|
|
92
|
+
write("extra-openai-models.yaml", "".join(endpoints.yaml_block(a, s) for a, s in
|
|
93
|
+
(("eye", "eye"), ("eye2", "eye2"), ("blind", "blind"),
|
|
94
|
+
("unknown", "unknown"), ("echo", "echo"), ("tiny", "tiny"),
|
|
95
|
+
("mg", "merger")))
|
|
96
|
+
+ endpoints.yaml_block("planned", "planned", "plan"))
|
|
97
|
+
write("model-overrides.json", {"eye": {"vision": True}, "eye2": {"vision": True}, "echo": {"vision": True},
|
|
98
|
+
"blind": {"vision": False}, "planned": {"vision": True},
|
|
99
|
+
"tiny": {"vision": True, "context_window": 3000}})
|
|
100
|
+
write("gateway.json", {"max_calls": 5, "max_explorers": 2})
|
|
101
|
+
write("collections.json", {
|
|
102
|
+
"vision": {"members": ["eye", "blind", "unknown", "echo"], "description": "photos"},
|
|
103
|
+
"merged": {"members": ["eye", "eye2"], "reply": "merged", "merger": "mg", "description": "x"},
|
|
104
|
+
"blindonly": {"members": ["blind", "unknown", "planned"], "description": "x"},
|
|
105
|
+
"small": {"members": ["tiny"], "description": "x"},
|
|
106
|
+
"text": {"members": ["eye"], "description": "x"}})
|
|
107
|
+
|
|
108
|
+
import client
|
|
109
|
+
import gateway
|
|
110
|
+
import serve
|
|
111
|
+
gateway._ask_llm = lambda cmd, prompt: (LLM_CALLS.append((cmd[2], prompt)) or f"via llm: {cmd[2]}", "")
|
|
112
|
+
gw = serve_on(gateway.Handler)
|
|
113
|
+
url = f"http://127.0.0.1:{gw.server_port}"
|
|
114
|
+
|
|
115
|
+
def post(body: dict) -> tuple[int, dict]:
|
|
116
|
+
req = urllib.request.Request(url + "/v1/chat/completions", json.dumps(body).encode(),
|
|
117
|
+
{"Content-Type": "application/json"})
|
|
118
|
+
try:
|
|
119
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
120
|
+
return r.status, json.loads(r.read())
|
|
121
|
+
except urllib.error.HTTPError as e:
|
|
122
|
+
return e.code, json.loads(e.read())
|
|
123
|
+
|
|
124
|
+
question = [{"role": "system", "content": "You price estate-sale items."},
|
|
125
|
+
{"role": "user", "content": [{"type": "text", "text": "What is this chair worth?"},
|
|
126
|
+
{"type": "image_url", "image_url": {"url": DATA_URL, "detail": "low"}},
|
|
127
|
+
{"type": "image_url", "image_url": {"url": PHOTO}}]}]
|
|
128
|
+
saved_err = sys.stderr
|
|
129
|
+
try:
|
|
130
|
+
# 1. Images reach every seat that can see, intact, past BENCH_CLIENT=llm.
|
|
131
|
+
sys.stderr = log = io.StringIO()
|
|
132
|
+
status, r = post({"model": "bench/vision", "messages": question, "with": ["mg"]})
|
|
133
|
+
sys.stderr = saved_err
|
|
134
|
+
assert status == 200, (status, r)
|
|
135
|
+
b, content = r["bench"], r["choices"][0]["message"]["content"]
|
|
136
|
+
sat = {s["alias"] for s in b["seats"]}
|
|
137
|
+
assert {"eye", "echo"} <= sat and not sat & {"blind", "unknown", "mg"}, sat
|
|
138
|
+
explorers = [s["alias"] for s in b["seats"] if s["seat"].startswith("explorer")]
|
|
139
|
+
assert len(explorers) == 1 and explorers[0] in ("eye2", "tiny"), explorers # only models that see
|
|
140
|
+
assert (b["shape"], b["shape_by"]) == ("vision", "the request carries images"), b
|
|
141
|
+
assert {(k["seat"], k["alias"]) for k in b["skipped"]} == {("member", "blind"), ("member", "unknown"),
|
|
142
|
+
("with", "mg")}, b["skipped"]
|
|
143
|
+
why = {k["alias"]: k["why"] for k in b["skipped"]}
|
|
144
|
+
assert "does not take images" in why["blind"] and "Vision / OCR" in why["unknown"], why
|
|
145
|
+
assert not LLM_CALLS, LLM_CALLS
|
|
146
|
+
assert all(q["messages"] == question for q in ASKED), "a seat got other messages than were sent"
|
|
147
|
+
assert {q["model"] for q in ASKED} == sat
|
|
148
|
+
assert next(s for s in b["seats"] if s["alias"] == "eye")["answer"].endswith("(saw the images)")
|
|
149
|
+
echo = next(s for s in b["seats"] if s["alias"] == "echo")
|
|
150
|
+
assert echo["status"] == "error" and "[image: data URL, 1 KB]" in echo["error"], echo
|
|
151
|
+
assert "blind (does not take images" in content, content
|
|
152
|
+
|
|
153
|
+
# Never the bytes, nor the signed URL's signature or password: not in the reply,
|
|
154
|
+
# the access log, any file in the data folder, or the dashboard's card.
|
|
155
|
+
record = json.loads((TMP / "runs" / "gateway" / "calls.jsonl").read_text(encoding="utf-8").splitlines()[-1])
|
|
156
|
+
assert "[image: data URL, 1 KB]" in record["prompt"], record["prompt"]
|
|
157
|
+
assert "[image: https://photos.example/chair.jpg?...]" in record["prompt"], record["prompt"]
|
|
158
|
+
assert record["key"] == "local" and record["skipped"] == b["skipped"]
|
|
159
|
+
card = serve.gateway_card()
|
|
160
|
+
assert "blind –" in card and "not asked: does not take images" in card
|
|
161
|
+
shown = [json.dumps(r), log.getvalue(), card] + [p.read_text(encoding="utf-8", errors="replace")
|
|
162
|
+
for p in TMP.rglob("*") if p.is_file()]
|
|
163
|
+
for secret in (PIXEL[30:60], "SECRET123", "user:pw"):
|
|
164
|
+
where = [text[max(0, text.find(secret) - 300):text.find(secret) + 50] for text in shown if secret in text]
|
|
165
|
+
assert not where, f"{secret!r} was written down: {where}"
|
|
166
|
+
|
|
167
|
+
# 2. A text request takes the llm path, as before, with the same bench keys.
|
|
168
|
+
write("gateway.json", {"max_calls": 5, "max_explorers": 0}) # members only from here
|
|
169
|
+
ASKED.clear()
|
|
170
|
+
status, r = post({"model": "bench/text", "messages": [{"role": "user", "content": "say hi"}]})
|
|
171
|
+
assert status == 200 and LLM_CALLS == [("eye", "say hi")] and not ASKED, (status, LLM_CALLS, ASKED)
|
|
172
|
+
assert set(r["bench"]) == {"panel", "collection", "shape", "shape_by", "seats", "verdict"}, r["bench"]
|
|
173
|
+
|
|
174
|
+
# 3. Merged: the seats see the images; the merger gets text, placeholders and answers.
|
|
175
|
+
LLM_CALLS.clear()
|
|
176
|
+
status, r = post({"model": "bench/merged", "messages": question})
|
|
177
|
+
assert status == 200 and r["bench"]["reply"] == "merged", r
|
|
178
|
+
[(merger, said)] = LLM_CALLS
|
|
179
|
+
assert merger == "mg" and "[image: data URL, 1 KB]" in said and "(saw the images)" in said, said
|
|
180
|
+
assert PIXEL[30:60] not in said and "SECRET123" not in said
|
|
181
|
+
|
|
182
|
+
# 4. No seat can see and no explorer seat to fill: refused, nobody asked.
|
|
183
|
+
ASKED.clear()
|
|
184
|
+
status, r = post({"model": "bench/blindonly", "messages": question})
|
|
185
|
+
message = r["error"]["message"]
|
|
186
|
+
assert status == 400 and "can see images" in message and "blind" in message and "unknown" in message, r
|
|
187
|
+
assert "Responses API" in message and not ASKED, message # a plan model gets no images yet
|
|
188
|
+
assert "Responses API" in client.chat("planned", "", messages=question)["error"]
|
|
189
|
+
|
|
190
|
+
# 5. Images count in the context check: two of them overflow a 3,000-token window.
|
|
191
|
+
status, r = post({"model": "bench/small", "messages": question})
|
|
192
|
+
assert status == 413 and "each image counted" in r["error"]["message"], r
|
|
193
|
+
|
|
194
|
+
# 6. Images no seat could be sent are refused, never dropped.
|
|
195
|
+
for part in ({"type": "image_url", "image_url": {"url": "file:///etc/passwd"}},
|
|
196
|
+
{"type": "image_url", "image_url": {"url": "data:text/plain;base64,aGk="}},
|
|
197
|
+
{"type": "image_url"}, {"type": "input_image", "image_url": DATA_URL}):
|
|
198
|
+
status, r = post({"model": "bench/text", "messages": [{"role": "user", "content": [part]}]})
|
|
199
|
+
assert status == 400, (part, status, r)
|
|
200
|
+
|
|
201
|
+
# 7. Over the body cap: a 413 that says what to do, before any of it is read.
|
|
202
|
+
conn = http.client.HTTPConnection("127.0.0.1", gw.server_port, timeout=10)
|
|
203
|
+
conn.putrequest("POST", "/v1/chat/completions")
|
|
204
|
+
conn.putheader("Content-Type", "application/json")
|
|
205
|
+
conn.putheader("Content-Length", str(gateway.MAX_BODY + 1))
|
|
206
|
+
conn.endheaders()
|
|
207
|
+
resp = conn.getresponse()
|
|
208
|
+
assert resp.status == 413 and "http(s) URLs" in json.loads(resp.read())["error"]["message"]
|
|
209
|
+
conn.close()
|
|
210
|
+
|
|
211
|
+
# 8. The redaction itself: an inline image, a bare base64 run, a URL's secrets in any form.
|
|
212
|
+
assert gateway.redact(f"x {DATA_URL} y") == "x [image: data URL, 1 KB] y"
|
|
213
|
+
assert gateway.redact("cannot fetch 'https://photos.example/chair.jpg?sig=SECRET123'") \
|
|
214
|
+
== "cannot fetch 'https://photos.example/chair.jpg?...'"
|
|
215
|
+
assert gateway.redact("bad http://[::1?x") == "bad [url]"
|
|
216
|
+
assert gateway.redact("A" * 150) == "[base64, 1 KB]" and gateway.redact("short AAAA") == "short AAAA"
|
|
217
|
+
assert gateway.placeholder(PHOTO) == "[image: https://photos.example/chair.jpg?...]"
|
|
218
|
+
finally:
|
|
219
|
+
sys.stderr = saved_err
|
|
220
|
+
upstream.shutdown()
|
|
221
|
+
gw.shutdown()
|
|
222
|
+
shutil.rmtree(TMP, ignore_errors=True)
|
|
223
|
+
print("ok")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
sys.exit(main())
|