@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -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 +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -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/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -1,228 +1,231 @@
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("model-roles.json", {"models": {"eye2": "vision"}}) # a specialist: sits only for pictures
101
+ write("gateway.json", {"max_calls": 5, "max_explorers": 2})
102
+ write("collections.json", {
103
+ "vision": {"members": ["eye", "blind", "unknown", "echo"], "description": "photos"},
104
+ "merged": {"members": ["eye", "eye2"], "reply": "merged", "merger": "mg", "description": "x"},
105
+ "blindonly": {"members": ["blind", "unknown", "planned"], "description": "x"},
106
+ "small": {"members": ["tiny"], "description": "x"},
107
+ "text": {"members": ["eye", "eye2"], "description": "x"}})
108
+
109
+ import client
110
+ import gateway
111
+ import serve
112
+ gateway._ask_llm = lambda cmd, prompt: (LLM_CALLS.append((cmd[2], prompt)) or f"via llm: {cmd[2]}", "")
113
+ gw = serve_on(gateway.Handler)
114
+ url = f"http://127.0.0.1:{gw.server_port}"
115
+
116
+ def post(body: dict) -> tuple[int, dict]:
117
+ req = urllib.request.Request(url + "/v1/chat/completions", json.dumps(body).encode(),
118
+ {"Content-Type": "application/json"})
119
+ try:
120
+ with urllib.request.urlopen(req, timeout=60) as r:
121
+ return r.status, json.loads(r.read())
122
+ except urllib.error.HTTPError as e:
123
+ return e.code, json.loads(e.read())
124
+
125
+ question = [{"role": "system", "content": "You price estate-sale items."},
126
+ {"role": "user", "content": [{"type": "text", "text": "What is this chair worth?"},
127
+ {"type": "image_url", "image_url": {"url": DATA_URL, "detail": "low"}},
128
+ {"type": "image_url", "image_url": {"url": PHOTO}}]}]
129
+ saved_err = sys.stderr
130
+ try:
131
+ # 1. Images reach every seat that can see, intact, past BENCH_CLIENT=llm.
132
+ sys.stderr = log = io.StringIO()
133
+ status, r = post({"model": "bench/vision", "messages": question, "with": ["mg"]})
134
+ sys.stderr = saved_err
135
+ assert status == 200, (status, r)
136
+ b, content = r["bench"], r["choices"][0]["message"]["content"]
137
+ sat = {s["alias"] for s in b["seats"]}
138
+ assert {"eye", "echo"} <= sat and not sat & {"blind", "unknown", "mg"}, sat
139
+ explorers = [s["alias"] for s in b["seats"] if s["seat"].startswith("explorer")]
140
+ assert len(explorers) == 1 and explorers[0] in ("eye2", "tiny"), explorers # only models that see
141
+ assert (b["shape"], b["shape_by"]) == ("vision", "the request carries images"), b
142
+ assert {(k["seat"], k["alias"]) for k in b["skipped"]} == {("member", "blind"), ("member", "unknown"),
143
+ ("with", "mg")}, b["skipped"]
144
+ why = {k["alias"]: k["why"] for k in b["skipped"]}
145
+ assert "does not take images" in why["blind"] and "Vision / OCR" in why["unknown"], why
146
+ assert not LLM_CALLS, LLM_CALLS
147
+ assert all(q["messages"] == question for q in ASKED), "a seat got other messages than were sent"
148
+ assert {q["model"] for q in ASKED} == sat
149
+ assert next(s for s in b["seats"] if s["alias"] == "eye")["answer"].endswith("(saw the images)")
150
+ echo = next(s for s in b["seats"] if s["alias"] == "echo")
151
+ assert echo["status"] == "error" and "[image: data URL, 1 KB]" in echo["error"], echo
152
+ assert "blind (does not take images" in content, content
153
+
154
+ # Never the bytes, nor the signed URL's signature or password: not in the reply,
155
+ # the access log, any file in the data folder, or the dashboard's card.
156
+ record = json.loads((TMP / "runs" / "gateway" / "calls.jsonl").read_text(encoding="utf-8").splitlines()[-1])
157
+ assert "[image: data URL, 1 KB]" in record["prompt"], record["prompt"]
158
+ assert "[image: https://photos.example/chair.jpg?...]" in record["prompt"], record["prompt"]
159
+ assert record["key"] == "local" and record["skipped"] == b["skipped"]
160
+ card = serve.gateway_card()
161
+ assert "blind –" in card and "not asked: does not take images" in card
162
+ shown = [json.dumps(r), log.getvalue(), card] + [p.read_text(encoding="utf-8", errors="replace")
163
+ for p in TMP.rglob("*") if p.is_file()]
164
+ for secret in (PIXEL[30:60], "SECRET123", "user:pw"):
165
+ where = [text[max(0, text.find(secret) - 300):text.find(secret) + 50] for text in shown if secret in text]
166
+ assert not where, f"{secret!r} was written down: {where}"
167
+
168
+ # 2. A text request takes the llm path, as before, with the same bench keys;
169
+ # the vision specialist among the members is not asked, and is named.
170
+ write("gateway.json", {"max_calls": 5, "max_explorers": 0}) # members only from here
171
+ ASKED.clear()
172
+ status, r = post({"model": "bench/text", "messages": [{"role": "user", "content": "say hi"}]})
173
+ assert status == 200 and LLM_CALLS == [("eye", "say hi")] and not ASKED, (status, LLM_CALLS, ASKED)
174
+ assert set(r["bench"]) == {"panel", "collection", "shape", "shape_by", "seats", "verdict", "skipped"}, r["bench"]
175
+ assert [(k["alias"], "vision specialist" in k["why"]) for k in r["bench"]["skipped"]] == [("eye2", True)]
176
+
177
+ # 3. Merged: the seats see the images; the merger gets text, placeholders and answers.
178
+ LLM_CALLS.clear()
179
+ status, r = post({"model": "bench/merged", "messages": question})
180
+ assert status == 200 and r["bench"]["reply"] == "merged", r
181
+ [(merger, said)] = LLM_CALLS
182
+ assert merger == "mg" and "[image: data URL, 1 KB]" in said and "(saw the images)" in said, said
183
+ assert PIXEL[30:60] not in said and "SECRET123" not in said
184
+
185
+ # 4. No seat can see and no explorer seat to fill: refused, nobody asked.
186
+ ASKED.clear()
187
+ status, r = post({"model": "bench/blindonly", "messages": question})
188
+ message = r["error"]["message"]
189
+ assert status == 400 and "can see images" in message and "blind" in message and "unknown" in message, r
190
+ assert "Responses API" in message and not ASKED, message # a plan model gets no images yet
191
+ assert "Responses API" in client.chat("planned", "", messages=question)["error"]
192
+
193
+ # 5. Images count in the context check: two of them overflow a 3,000-token window.
194
+ status, r = post({"model": "bench/small", "messages": question})
195
+ assert status == 413 and "each image counted" in r["error"]["message"], r
196
+
197
+ # 6. Images no seat could be sent are refused, never dropped.
198
+ for part in ({"type": "image_url", "image_url": {"url": "file:///etc/passwd"}},
199
+ {"type": "image_url", "image_url": {"url": "data:text/plain;base64,aGk="}},
200
+ {"type": "image_url"}, {"type": "input_image", "image_url": DATA_URL}):
201
+ status, r = post({"model": "bench/text", "messages": [{"role": "user", "content": [part]}]})
202
+ assert status == 400, (part, status, r)
203
+
204
+ # 7. Over the body cap: a 413 that says what to do, before any of it is read.
205
+ conn = http.client.HTTPConnection("127.0.0.1", gw.server_port, timeout=10)
206
+ conn.putrequest("POST", "/v1/chat/completions")
207
+ conn.putheader("Content-Type", "application/json")
208
+ conn.putheader("Content-Length", str(gateway.MAX_BODY + 1))
209
+ conn.endheaders()
210
+ resp = conn.getresponse()
211
+ assert resp.status == 413 and "http(s) URLs" in json.loads(resp.read())["error"]["message"]
212
+ conn.close()
213
+
214
+ # 8. The redaction itself: an inline image, a bare base64 run, a URL's secrets in any form.
215
+ assert gateway.redact(f"x {DATA_URL} y") == "x [image: data URL, 1 KB] y"
216
+ assert gateway.redact("cannot fetch 'https://photos.example/chair.jpg?sig=SECRET123'") \
217
+ == "cannot fetch 'https://photos.example/chair.jpg?...'"
218
+ assert gateway.redact("bad http://[::1?x") == "bad [url]"
219
+ assert gateway.redact("A" * 150) == "[base64, 1 KB]" and gateway.redact("short AAAA") == "short AAAA"
220
+ assert gateway.placeholder(PHOTO) == "[image: https://photos.example/chair.jpg?...]"
221
+ finally:
222
+ sys.stderr = saved_err
223
+ upstream.shutdown()
224
+ gw.shutdown()
225
+ shutil.rmtree(TMP, ignore_errors=True)
226
+ print("ok")
227
+ return 0
228
+
229
+
230
+ if __name__ == "__main__":
231
+ sys.exit(main())