@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_merged.py
CHANGED
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
"""Merged replies through the real gateway handler and client, against a fake upstream.
|
|
2
|
-
|
|
3
|
-
python scripts/test_merged.py
|
|
4
|
-
|
|
5
|
-
RELEASE-PLAN Phase 5, "Merged replies": the panel runs once per mode against
|
|
6
|
-
a fake OpenAI-compatible server reached by the built-in client. With
|
|
7
|
-
`merged`, every seat is still in `bench` and in the offload log and the
|
|
8
|
-
merger has its own call id; when the merger fails, or no merger has the
|
|
9
|
-
context window for the merge, the reply falls back to every answer with a
|
|
10
|
-
note, and a failed merger is still logged. Also: the default stays `all`
|
|
11
|
-
with the same `bench` keys as before, the suffix and body field and which
|
|
12
|
-
wins, `/v1/models` variants, the merger chosen by verdict record, and the
|
|
13
|
-
editor's save. BENCHWARMERS_HOME and LLM_USER_PATH name temp folders before
|
|
14
|
-
anything is imported; the key comes from the environment.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import json
|
|
20
|
-
import os
|
|
21
|
-
import pathlib
|
|
22
|
-
import shutil
|
|
23
|
-
import sys
|
|
24
|
-
import tempfile
|
|
25
|
-
import threading
|
|
26
|
-
import urllib.error
|
|
27
|
-
import urllib.request
|
|
28
|
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
-
|
|
30
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
31
|
-
TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-merged-"))
|
|
32
|
-
(TMP / "config").mkdir()
|
|
33
|
-
(TMP / "llm").mkdir()
|
|
34
|
-
os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="builtin",
|
|
35
|
-
BENCHWARMERS_KEY_FAKE="sk-test")
|
|
36
|
-
sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
|
|
37
|
-
|
|
38
|
-
ASKED: list[dict] = [] # every chat request the fake upstream received
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class Upstream(BaseHTTPRequestHandler):
|
|
42
|
-
def do_GET(self): # noqa: N802
|
|
43
|
-
self._send({"data": [{"id": i} for i in ("m1", "m2", "merger", "broken", "tiny")]})
|
|
44
|
-
|
|
45
|
-
def do_POST(self): # noqa: N802
|
|
46
|
-
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
47
|
-
ASKED.append(body)
|
|
48
|
-
if body["model"] == "broken": # a 400 is not retried
|
|
49
|
-
return self._send({"error": {"message": "model is broken"}}, 400)
|
|
50
|
-
self._send({"model": body["model"], "choices": [{"message": {"content": f"answer from {body['model']}"}}]})
|
|
51
|
-
|
|
52
|
-
def _send(self, data, status=200):
|
|
53
|
-
raw = json.dumps(data).encode()
|
|
54
|
-
self.send_response(status)
|
|
55
|
-
self.send_header("Content-Type", "application/json")
|
|
56
|
-
self.send_header("Content-Length", str(len(raw)))
|
|
57
|
-
self.end_headers()
|
|
58
|
-
self.wfile.write(raw)
|
|
59
|
-
|
|
60
|
-
def log_message(self, *_a):
|
|
61
|
-
pass
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def serve_on(handler) -> ThreadingHTTPServer:
|
|
65
|
-
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
66
|
-
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
67
|
-
return server
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def write(name: str, data) -> None:
|
|
71
|
-
(TMP / "config" / name).write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def main() -> int:
|
|
75
|
-
upstream = serve_on(Upstream)
|
|
76
|
-
write("endpoints.json", {"fake": {"base": f"http://127.0.0.1:{upstream.server_port}/v1", "key": "fake",
|
|
77
|
-
"default": True, "kind": "provider"}})
|
|
78
|
-
import endpoints
|
|
79
|
-
write("extra-openai-models.yaml", "".join(endpoints.yaml_block(a, s) for a, s in
|
|
80
|
-
(("m1", "m1"), ("m2", "m2"), ("mg", "merger"),
|
|
81
|
-
("broken", "broken"), ("tiny", "tiny"))))
|
|
82
|
-
write("model-overrides.json", {"tiny": {"context_window": 50}})
|
|
83
|
-
write("gateway.json", {"max_calls": 2, "max_explorers": 0}) # no explorers: no router, no shape picker
|
|
84
|
-
|
|
85
|
-
def collection(**kw):
|
|
86
|
-
write("collections.json", {"core": {"members": ["m1", "m2"], "description": "x", **kw}})
|
|
87
|
-
|
|
88
|
-
import gateway
|
|
89
|
-
import offload
|
|
90
|
-
import serve
|
|
91
|
-
gw = serve_on(gateway.Handler)
|
|
92
|
-
url = f"http://127.0.0.1:{gw.server_port}"
|
|
93
|
-
|
|
94
|
-
def post(body: dict) -> tuple[int, dict | str]:
|
|
95
|
-
req = urllib.request.Request(url + "/v1/chat/completions", json.dumps(body).encode(),
|
|
96
|
-
{"Content-Type": "application/json"})
|
|
97
|
-
try:
|
|
98
|
-
with urllib.request.urlopen(req, timeout=60) as r:
|
|
99
|
-
text = r.read().decode()
|
|
100
|
-
return r.status, (text if body.get("stream") else json.loads(text))
|
|
101
|
-
except urllib.error.HTTPError as e:
|
|
102
|
-
return e.code, json.loads(e.read())
|
|
103
|
-
|
|
104
|
-
def ask(model: str, **extra) -> tuple[dict, dict, str]:
|
|
105
|
-
status, r = post({"model": model, "messages": [{"role": "user", "content": "say hi"}], **extra})
|
|
106
|
-
assert status == 200, (status, r)
|
|
107
|
-
return r, r["bench"], r["choices"][0]["message"]["content"]
|
|
108
|
-
|
|
109
|
-
def logged() -> set[str]:
|
|
110
|
-
return {e["call"] for e in offload.entries() if e.get("call")}
|
|
111
|
-
|
|
112
|
-
try:
|
|
113
|
-
# 1. The default is every answer, with the bench keys clients get today.
|
|
114
|
-
collection()
|
|
115
|
-
r, b, content = ask("bench/core")
|
|
116
|
-
assert set(b) == {"panel", "collection", "shape", "shape_by", "seats", "verdict"}, b
|
|
117
|
-
assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and all(s["call"] in content for s in b["seats"])
|
|
118
|
-
assert all(s["call"] in logged() for s in b["seats"])
|
|
119
|
-
assert not any(q["messages"][0]["content"] == gateway.MERGE_SYSTEM for q in ASKED), "merged by default"
|
|
120
|
-
|
|
121
|
-
# 2. Merged by suffix: one answer, its own call id, every seat kept and logged.
|
|
122
|
-
collection(merger="mg")
|
|
123
|
-
ASKED.clear()
|
|
124
|
-
r, b, content = ask("bench/core:merged")
|
|
125
|
-
merger = b["merger"]
|
|
126
|
-
assert b["reply"] == "merged" and merger["call"] == b["panel"] + ".merger" and merger["alias"] == "mg", b
|
|
127
|
-
assert content.startswith("answer from merger") and merger["call"] in content, content
|
|
128
|
-
assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and all(s["answer"] for s in b["seats"])
|
|
129
|
-
assert {s["call"] for s in b["seats"]} | {merger["call"]} <= logged()
|
|
130
|
-
merge_ask = next(q for q in ASKED if q["model"] == "merger")
|
|
131
|
-
said = merge_ask["messages"][1]["content"]
|
|
132
|
-
assert merge_ask["messages"][0]["content"] == gateway.MERGE_SYSTEM
|
|
133
|
-
assert "say hi" in said and "answer from m1" in said and "answer from m2" in said, said
|
|
134
|
-
# The dashboard finds the merger in the call log, so its verdict dialog opens.
|
|
135
|
-
assert serve.gateway_seat(merger["call"])["seat"] == "merger"
|
|
136
|
-
|
|
137
|
-
# 3. The merger fails (body field this time): every answer, a note, the failed merger logged.
|
|
138
|
-
collection(merger="broken")
|
|
139
|
-
r, b, content = ask("bench/core", bench={"reply": "merged"})
|
|
140
|
-
assert b["reply"] == "all" and "broken" in b["note"] and b["merger"]["status"] == "error", b
|
|
141
|
-
assert content.startswith("_Merged reply unavailable") and all(s["call"] in content for s in b["seats"])
|
|
142
|
-
assert b["merger"]["call"] in logged() and all(s["call"] in logged() for s in b["seats"])
|
|
143
|
-
|
|
144
|
-
# 4. No merger fits the merge: every answer, a note, and no merge call spent.
|
|
145
|
-
collection(merger="tiny")
|
|
146
|
-
ASKED.clear()
|
|
147
|
-
r, b, content = ask("bench/core:merged")
|
|
148
|
-
assert b["reply"] == "all" and "context window" in b["note"] and "merger" not in b, b
|
|
149
|
-
assert content.startswith("_Merged reply unavailable") and not any(q["model"] == "tiny" for q in ASKED)
|
|
150
|
-
assert all(s["call"] in logged() for s in b["seats"])
|
|
151
|
-
|
|
152
|
-
# 5. Most specific wins: a merged collection, asked for :all, and the body over the suffix.
|
|
153
|
-
collection(reply="merged", merger="mg")
|
|
154
|
-
assert "merger" not in ask("bench/core:all")[1]
|
|
155
|
-
assert "merger" not in ask("bench/core:merged", bench={"reply": "all"})[1]
|
|
156
|
-
assert ask("bench/core")[1]["reply"] == "merged"
|
|
157
|
-
assert post({"model": "bench/core", "messages": [{"role": "user", "content": "x"}],
|
|
158
|
-
"bench": {"reply": "both"}})[0] == 400
|
|
159
|
-
assert post({"model": "bench/core:both", "messages": [{"role": "user", "content": "x"}]})[0] == 404
|
|
160
|
-
# Streaming: the merged answer as the one chunk, the seats beside it.
|
|
161
|
-
chunks = [json.loads(line[6:]) for line in post({"model": "bench/core", "stream": True, "messages":
|
|
162
|
-
[{"role": "user", "content": "x"}]})[1].splitlines() if line.startswith("data: {")]
|
|
163
|
-
assert chunks[0]["choices"][0]["delta"]["content"].startswith("answer from merger")
|
|
164
|
-
assert len(chunks[0]["bench"]["seats"]) == 2
|
|
165
|
-
|
|
166
|
-
# 6. /v1/models: each collection and its other reply mode.
|
|
167
|
-
with urllib.request.urlopen(url + "/v1/models", timeout=10) as resp:
|
|
168
|
-
listed = {m["id"]: m["reply"] for m in json.load(resp)["data"]}
|
|
169
|
-
assert listed == {"bench/core": "merged", "bench/core:all": "all"}, listed
|
|
170
|
-
|
|
171
|
-
# 7. An empty merger: the member with the most useful verdicts on the shape,
|
|
172
|
-
# else (no verdicts, and no suite scores for a router's primary) the first member.
|
|
173
|
-
assert next(gateway.merger_candidates({}, ["m1", "m2"], "review", False)) == "m1"
|
|
174
|
-
offload.add("m2", "a task (bench/core, review)", True, "", "pZ.member2")
|
|
175
|
-
offload.add("m1", "a task (bench/core, design)", True, "", "pZ.member1")
|
|
176
|
-
assert next(gateway.merger_candidates({}, ["m1", "m2"], "review", False)) == "m2"
|
|
177
|
-
|
|
178
|
-
# 8. The editor: reply and merger saved, and checked.
|
|
179
|
-
for reply, merger_alias in (("both", ""), ("merged", "nope")):
|
|
180
|
-
assert not serve.save_collection("chat", ["m1"], "", "", [], reply, merger_alias)["ok"]
|
|
181
|
-
assert serve.save_collection("chat", ["m1"], "", "", [], "merged", "mg")["ok"]
|
|
182
|
-
saved = json.loads((TMP / "config" / "collections.json").read_text(encoding="utf-8"))["chat"]
|
|
183
|
-
assert (saved["reply"], saved["merger"]) == ("merged", "mg"), saved
|
|
184
|
-
|
|
185
|
-
# 9. A retired model a collection still names (member, `with`, merger) is
|
|
186
|
-
# not called: skipped with why, and the merge goes to a merger chosen by verdicts.
|
|
187
|
-
write("retired.json", {"gone": {"served": "gone", "at": "2026-10-20T05:00:00+02:00", "why": "withdrawn"}})
|
|
188
|
-
collection(members=["m1", "gone"], merger="gone")
|
|
189
|
-
ASKED.clear()
|
|
190
|
-
r, b, content = ask("bench/core:merged", **{"with": ["gone"]})
|
|
191
|
-
assert [s["alias"] for s in b["seats"]] == ["m1"] and not any(q["model"] == "gone" for q in ASKED), b
|
|
192
|
-
assert {(k["seat"], k["alias"]) for k in b["skipped"]} == {("member", "gone"), ("merger", "gone")}, b
|
|
193
|
-
assert all("retired from the bench on 2026-10-20" in k["why"] for k in b["skipped"]), b["skipped"]
|
|
194
|
-
assert b["reply"] == "merged" and b["merger"]["alias"] != "gone", b
|
|
195
|
-
# Asked for every answer, the reply says who was not asked; `with` a retired model is skipped too.
|
|
196
|
-
collection(members=["m1"])
|
|
197
|
-
r, b, content = ask("bench/core", **{"with": ["gone"]})
|
|
198
|
-
assert b["skipped"] == [{"seat": "with", "alias": "gone", "why": b["skipped"][0]["why"]}], b
|
|
199
|
-
assert "Not asked: gone (retired from the bench" in content and not any(q["model"] == "gone" for q in ASKED)
|
|
200
|
-
# Skipped before the max_calls cap (2 here), so the member after it still sits.
|
|
201
|
-
collection(members=["gone", "m1", "m2"])
|
|
202
|
-
r, b, content = ask("bench/core")
|
|
203
|
-
assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and [k["alias"] for k in b["skipped"]] == ["gone"], b
|
|
204
|
-
(TMP / "config" / "retired.json").unlink()
|
|
205
|
-
finally:
|
|
206
|
-
upstream.shutdown()
|
|
207
|
-
gw.shutdown()
|
|
208
|
-
shutil.rmtree(TMP, ignore_errors=True)
|
|
209
|
-
print("ok")
|
|
210
|
-
return 0
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if __name__ == "__main__":
|
|
214
|
-
sys.exit(main())
|
|
1
|
+
"""Merged replies through the real gateway handler and client, against a fake upstream.
|
|
2
|
+
|
|
3
|
+
python scripts/test_merged.py
|
|
4
|
+
|
|
5
|
+
RELEASE-PLAN Phase 5, "Merged replies": the panel runs once per mode against
|
|
6
|
+
a fake OpenAI-compatible server reached by the built-in client. With
|
|
7
|
+
`merged`, every seat is still in `bench` and in the offload log and the
|
|
8
|
+
merger has its own call id; when the merger fails, or no merger has the
|
|
9
|
+
context window for the merge, the reply falls back to every answer with a
|
|
10
|
+
note, and a failed merger is still logged. Also: the default stays `all`
|
|
11
|
+
with the same `bench` keys as before, the suffix and body field and which
|
|
12
|
+
wins, `/v1/models` variants, the merger chosen by verdict record, and the
|
|
13
|
+
editor's save. BENCHWARMERS_HOME and LLM_USER_PATH name temp folders before
|
|
14
|
+
anything is imported; the key comes from the environment.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import threading
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
|
|
30
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
31
|
+
TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-merged-"))
|
|
32
|
+
(TMP / "config").mkdir()
|
|
33
|
+
(TMP / "llm").mkdir()
|
|
34
|
+
os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="builtin",
|
|
35
|
+
BENCHWARMERS_KEY_FAKE="sk-test")
|
|
36
|
+
sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
|
|
37
|
+
|
|
38
|
+
ASKED: list[dict] = [] # every chat request the fake upstream received
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Upstream(BaseHTTPRequestHandler):
|
|
42
|
+
def do_GET(self): # noqa: N802
|
|
43
|
+
self._send({"data": [{"id": i} for i in ("m1", "m2", "merger", "broken", "tiny")]})
|
|
44
|
+
|
|
45
|
+
def do_POST(self): # noqa: N802
|
|
46
|
+
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
47
|
+
ASKED.append(body)
|
|
48
|
+
if body["model"] == "broken": # a 400 is not retried
|
|
49
|
+
return self._send({"error": {"message": "model is broken"}}, 400)
|
|
50
|
+
self._send({"model": body["model"], "choices": [{"message": {"content": f"answer from {body['model']}"}}]})
|
|
51
|
+
|
|
52
|
+
def _send(self, data, status=200):
|
|
53
|
+
raw = json.dumps(data).encode()
|
|
54
|
+
self.send_response(status)
|
|
55
|
+
self.send_header("Content-Type", "application/json")
|
|
56
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
57
|
+
self.end_headers()
|
|
58
|
+
self.wfile.write(raw)
|
|
59
|
+
|
|
60
|
+
def log_message(self, *_a):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def serve_on(handler) -> ThreadingHTTPServer:
|
|
65
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
66
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
67
|
+
return server
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write(name: str, data) -> None:
|
|
71
|
+
(TMP / "config" / name).write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
upstream = serve_on(Upstream)
|
|
76
|
+
write("endpoints.json", {"fake": {"base": f"http://127.0.0.1:{upstream.server_port}/v1", "key": "fake",
|
|
77
|
+
"default": True, "kind": "provider"}})
|
|
78
|
+
import endpoints
|
|
79
|
+
write("extra-openai-models.yaml", "".join(endpoints.yaml_block(a, s) for a, s in
|
|
80
|
+
(("m1", "m1"), ("m2", "m2"), ("mg", "merger"),
|
|
81
|
+
("broken", "broken"), ("tiny", "tiny"))))
|
|
82
|
+
write("model-overrides.json", {"tiny": {"context_window": 50}})
|
|
83
|
+
write("gateway.json", {"max_calls": 2, "max_explorers": 0}) # no explorers: no router, no shape picker
|
|
84
|
+
|
|
85
|
+
def collection(**kw):
|
|
86
|
+
write("collections.json", {"core": {"members": ["m1", "m2"], "description": "x", **kw}})
|
|
87
|
+
|
|
88
|
+
import gateway
|
|
89
|
+
import offload
|
|
90
|
+
import serve
|
|
91
|
+
gw = serve_on(gateway.Handler)
|
|
92
|
+
url = f"http://127.0.0.1:{gw.server_port}"
|
|
93
|
+
|
|
94
|
+
def post(body: dict) -> tuple[int, dict | str]:
|
|
95
|
+
req = urllib.request.Request(url + "/v1/chat/completions", json.dumps(body).encode(),
|
|
96
|
+
{"Content-Type": "application/json"})
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
99
|
+
text = r.read().decode()
|
|
100
|
+
return r.status, (text if body.get("stream") else json.loads(text))
|
|
101
|
+
except urllib.error.HTTPError as e:
|
|
102
|
+
return e.code, json.loads(e.read())
|
|
103
|
+
|
|
104
|
+
def ask(model: str, **extra) -> tuple[dict, dict, str]:
|
|
105
|
+
status, r = post({"model": model, "messages": [{"role": "user", "content": "say hi"}], **extra})
|
|
106
|
+
assert status == 200, (status, r)
|
|
107
|
+
return r, r["bench"], r["choices"][0]["message"]["content"]
|
|
108
|
+
|
|
109
|
+
def logged() -> set[str]:
|
|
110
|
+
return {e["call"] for e in offload.entries() if e.get("call")}
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# 1. The default is every answer, with the bench keys clients get today.
|
|
114
|
+
collection()
|
|
115
|
+
r, b, content = ask("bench/core")
|
|
116
|
+
assert set(b) == {"panel", "collection", "shape", "shape_by", "seats", "verdict"}, b
|
|
117
|
+
assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and all(s["call"] in content for s in b["seats"])
|
|
118
|
+
assert all(s["call"] in logged() for s in b["seats"])
|
|
119
|
+
assert not any(q["messages"][0]["content"] == gateway.MERGE_SYSTEM for q in ASKED), "merged by default"
|
|
120
|
+
|
|
121
|
+
# 2. Merged by suffix: one answer, its own call id, every seat kept and logged.
|
|
122
|
+
collection(merger="mg")
|
|
123
|
+
ASKED.clear()
|
|
124
|
+
r, b, content = ask("bench/core:merged")
|
|
125
|
+
merger = b["merger"]
|
|
126
|
+
assert b["reply"] == "merged" and merger["call"] == b["panel"] + ".merger" and merger["alias"] == "mg", b
|
|
127
|
+
assert content.startswith("answer from merger") and merger["call"] in content, content
|
|
128
|
+
assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and all(s["answer"] for s in b["seats"])
|
|
129
|
+
assert {s["call"] for s in b["seats"]} | {merger["call"]} <= logged()
|
|
130
|
+
merge_ask = next(q for q in ASKED if q["model"] == "merger")
|
|
131
|
+
said = merge_ask["messages"][1]["content"]
|
|
132
|
+
assert merge_ask["messages"][0]["content"] == gateway.MERGE_SYSTEM
|
|
133
|
+
assert "say hi" in said and "answer from m1" in said and "answer from m2" in said, said
|
|
134
|
+
# The dashboard finds the merger in the call log, so its verdict dialog opens.
|
|
135
|
+
assert serve.gateway_seat(merger["call"])["seat"] == "merger"
|
|
136
|
+
|
|
137
|
+
# 3. The merger fails (body field this time): every answer, a note, the failed merger logged.
|
|
138
|
+
collection(merger="broken")
|
|
139
|
+
r, b, content = ask("bench/core", bench={"reply": "merged"})
|
|
140
|
+
assert b["reply"] == "all" and "broken" in b["note"] and b["merger"]["status"] == "error", b
|
|
141
|
+
assert content.startswith("_Merged reply unavailable") and all(s["call"] in content for s in b["seats"])
|
|
142
|
+
assert b["merger"]["call"] in logged() and all(s["call"] in logged() for s in b["seats"])
|
|
143
|
+
|
|
144
|
+
# 4. No merger fits the merge: every answer, a note, and no merge call spent.
|
|
145
|
+
collection(merger="tiny")
|
|
146
|
+
ASKED.clear()
|
|
147
|
+
r, b, content = ask("bench/core:merged")
|
|
148
|
+
assert b["reply"] == "all" and "context window" in b["note"] and "merger" not in b, b
|
|
149
|
+
assert content.startswith("_Merged reply unavailable") and not any(q["model"] == "tiny" for q in ASKED)
|
|
150
|
+
assert all(s["call"] in logged() for s in b["seats"])
|
|
151
|
+
|
|
152
|
+
# 5. Most specific wins: a merged collection, asked for :all, and the body over the suffix.
|
|
153
|
+
collection(reply="merged", merger="mg")
|
|
154
|
+
assert "merger" not in ask("bench/core:all")[1]
|
|
155
|
+
assert "merger" not in ask("bench/core:merged", bench={"reply": "all"})[1]
|
|
156
|
+
assert ask("bench/core")[1]["reply"] == "merged"
|
|
157
|
+
assert post({"model": "bench/core", "messages": [{"role": "user", "content": "x"}],
|
|
158
|
+
"bench": {"reply": "both"}})[0] == 400
|
|
159
|
+
assert post({"model": "bench/core:both", "messages": [{"role": "user", "content": "x"}]})[0] == 404
|
|
160
|
+
# Streaming: the merged answer as the one chunk, the seats beside it.
|
|
161
|
+
chunks = [json.loads(line[6:]) for line in post({"model": "bench/core", "stream": True, "messages":
|
|
162
|
+
[{"role": "user", "content": "x"}]})[1].splitlines() if line.startswith("data: {")]
|
|
163
|
+
assert chunks[0]["choices"][0]["delta"]["content"].startswith("answer from merger")
|
|
164
|
+
assert len(chunks[0]["bench"]["seats"]) == 2
|
|
165
|
+
|
|
166
|
+
# 6. /v1/models: each collection and its other reply mode.
|
|
167
|
+
with urllib.request.urlopen(url + "/v1/models", timeout=10) as resp:
|
|
168
|
+
listed = {m["id"]: m["reply"] for m in json.load(resp)["data"]}
|
|
169
|
+
assert listed == {"bench/core": "merged", "bench/core:all": "all"}, listed
|
|
170
|
+
|
|
171
|
+
# 7. An empty merger: the member with the most useful verdicts on the shape,
|
|
172
|
+
# else (no verdicts, and no suite scores for a router's primary) the first member.
|
|
173
|
+
assert next(gateway.merger_candidates({}, ["m1", "m2"], "review", False)) == "m1"
|
|
174
|
+
offload.add("m2", "a task (bench/core, review)", True, "", "pZ.member2")
|
|
175
|
+
offload.add("m1", "a task (bench/core, design)", True, "", "pZ.member1")
|
|
176
|
+
assert next(gateway.merger_candidates({}, ["m1", "m2"], "review", False)) == "m2"
|
|
177
|
+
|
|
178
|
+
# 8. The editor: reply and merger saved, and checked.
|
|
179
|
+
for reply, merger_alias in (("both", ""), ("merged", "nope")):
|
|
180
|
+
assert not serve.save_collection("chat", ["m1"], "", "", [], reply, merger_alias)["ok"]
|
|
181
|
+
assert serve.save_collection("chat", ["m1"], "", "", [], "merged", "mg")["ok"]
|
|
182
|
+
saved = json.loads((TMP / "config" / "collections.json").read_text(encoding="utf-8"))["chat"]
|
|
183
|
+
assert (saved["reply"], saved["merger"]) == ("merged", "mg"), saved
|
|
184
|
+
|
|
185
|
+
# 9. A retired model a collection still names (member, `with`, merger) is
|
|
186
|
+
# not called: skipped with why, and the merge goes to a merger chosen by verdicts.
|
|
187
|
+
write("retired.json", {"gone": {"served": "gone", "at": "2026-10-20T05:00:00+02:00", "why": "withdrawn"}})
|
|
188
|
+
collection(members=["m1", "gone"], merger="gone")
|
|
189
|
+
ASKED.clear()
|
|
190
|
+
r, b, content = ask("bench/core:merged", **{"with": ["gone"]})
|
|
191
|
+
assert [s["alias"] for s in b["seats"]] == ["m1"] and not any(q["model"] == "gone" for q in ASKED), b
|
|
192
|
+
assert {(k["seat"], k["alias"]) for k in b["skipped"]} == {("member", "gone"), ("merger", "gone")}, b
|
|
193
|
+
assert all("retired from the bench on 2026-10-20" in k["why"] for k in b["skipped"]), b["skipped"]
|
|
194
|
+
assert b["reply"] == "merged" and b["merger"]["alias"] != "gone", b
|
|
195
|
+
# Asked for every answer, the reply says who was not asked; `with` a retired model is skipped too.
|
|
196
|
+
collection(members=["m1"])
|
|
197
|
+
r, b, content = ask("bench/core", **{"with": ["gone"]})
|
|
198
|
+
assert b["skipped"] == [{"seat": "with", "alias": "gone", "why": b["skipped"][0]["why"]}], b
|
|
199
|
+
assert "Not asked: gone (retired from the bench" in content and not any(q["model"] == "gone" for q in ASKED)
|
|
200
|
+
# Skipped before the max_calls cap (2 here), so the member after it still sits.
|
|
201
|
+
collection(members=["gone", "m1", "m2"])
|
|
202
|
+
r, b, content = ask("bench/core")
|
|
203
|
+
assert [s["alias"] for s in b["seats"]] == ["m1", "m2"] and [k["alias"] for k in b["skipped"]] == ["gone"], b
|
|
204
|
+
(TMP / "config" / "retired.json").unlink()
|
|
205
|
+
finally:
|
|
206
|
+
upstream.shutdown()
|
|
207
|
+
gw.shutdown()
|
|
208
|
+
shutil.rmtree(TMP, ignore_errors=True)
|
|
209
|
+
print("ok")
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
sys.exit(main())
|