@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1

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