@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.0

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.
@@ -0,0 +1,429 @@
1
+ """Route mode on the gateway: one member, tools and all, against a fake upstream.
2
+
3
+ python scripts/test_route.py
4
+
5
+ The real gateway handler and the built-in client, with a fake OpenAI-style
6
+ upstream standing in for the models (no model is called): a tool-call round
7
+ trip in every format, streamed and not (OpenAI chat completions relayed
8
+ chunk by chunk, Anthropic Messages, Responses with a custom tool, Gemini,
9
+ Ollama), parallel calls whose deltas interleave, a whole JSON reply, the
10
+ request's fields passed on intact and an output budget cut to the model's
11
+ maximum; failover before the first piece (a 429, a member that sends
12
+ nothing in time, an empty reply) and an error after it in the format's
13
+ stream shape; every member failing keeps a status; stickiness across turns
14
+ and a fresh pick for another conversation; who may be routed to (tools,
15
+ images, ad hoc, retired, another API); a panel still refusing tools; and
16
+ what is recorded: the ledger (purpose route), routes.jsonl, one unjudged
17
+ offload per conversation, and the Gateway card with nothing added to Owed.
18
+ Temp data folder; BENCHWARMERS_HOME is set before anything is imported.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import http.client
24
+ import json
25
+ import os
26
+ import pathlib
27
+ import shutil
28
+ import sys
29
+ import tempfile
30
+ import threading
31
+ import time
32
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
33
+
34
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
35
+ TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-route-"))
36
+ (TMP / "config").mkdir()
37
+ (TMP / "llm").mkdir()
38
+ os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="builtin",
39
+ BENCHWARMERS_KEY_FAKE="sk-test")
40
+ for name in ("BENCHWARMERS_ADMIN_TOKEN", "BENCHWARMERS_REQUIRE_KEYS", "BENCH_ALLOWED_ORIGINS"):
41
+ os.environ.pop(name, None)
42
+ sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
43
+
44
+ import formats as F # noqa: E402
45
+
46
+ WEATHER = {"type": "function", "function": {"name": "get_weather", "description": "The weather in a city.",
47
+ "parameters": {"type": "object", "properties": {"city": {"type": "string"}},
48
+ "required": ["city"]}}}
49
+ PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
50
+
51
+
52
+ def chunk(delta=None, finish=None, **extra) -> dict:
53
+ return {"id": "chatcmpl-up", "object": "chat.completion.chunk", "created": 1, "model": "served-x",
54
+ "choices": [{"index": 0, "delta": delta or {}, "finish_reason": finish}], **extra}
55
+
56
+
57
+ def call_delta(index, cid=None, name=None, args="") -> dict:
58
+ fn = {"arguments": args, **({"name": name} if name else {})}
59
+ return {"index": index, **({"id": cid, "type": "function"} if cid else {}), "function": fn}
60
+
61
+
62
+ class Upstream(BaseHTTPRequestHandler):
63
+ """The models, by the name each request asks for. Each request's body is
64
+ kept (`seen`) to check what was passed on."""
65
+ seen: list[dict] = []
66
+
67
+ def log_message(self, *a):
68
+ pass
69
+
70
+ def _status(self, code: int, body: dict, **headers) -> None:
71
+ data = json.dumps(body).encode()
72
+ self.send_response(code)
73
+ for k, v in {"Content-Type": "application/json", "Content-Length": str(len(data)), **headers}.items():
74
+ self.send_header(k, v)
75
+ self.end_headers()
76
+ self.wfile.write(data)
77
+
78
+ def do_POST(self): # noqa: N802
79
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
80
+ Upstream.seen.append(body)
81
+ model, last = body["model"], body["messages"][-1]
82
+ if model == "busy":
83
+ return self._status(429, {"error": {"message": "Rate limit exceeded", "code": 429}}, **{"Retry-After": "30"})
84
+ if model.startswith("down"):
85
+ return self._status(503, {"error": {"message": "overloaded"}})
86
+ if model == "plain": # a whole JSON reply, not a stream
87
+ return self._status(200, {"id": "x", "model": "plain-1", "usage": {"prompt_tokens": 4, "completion_tokens": 2},
88
+ "choices": [{"index": 0, "finish_reason": "tool_calls", "message": {
89
+ "role": "assistant", "content": None, "tool_calls": [{
90
+ "id": "call_p", "type": "function",
91
+ "function": {"name": "get_weather", "arguments": "{\"city\": \"Oslo\"}"}}]}}]})
92
+ self.send_response(200)
93
+ self.send_header("Content-Type", "text/event-stream")
94
+ self.end_headers()
95
+ if model == "slow": # the headers, then nothing in time
96
+ time.sleep(2.5)
97
+ return
98
+ said = " ".join(F.text_of(m.get("content")) for m in body["messages"] if m.get("role") == "user")
99
+ if model == "hollow": # reasons, then says nothing
100
+ events = [chunk({"role": "assistant", "content": "", "reasoning_content": "Hmm."}), chunk({}, "stop")]
101
+ elif last.get("role") == "tool":
102
+ events = [chunk({"role": "assistant", "content": "It is 18C "}), chunk({"content": f"({last['content']})."}),
103
+ chunk({}, "stop"), chunk(None, None, usage={"prompt_tokens": 30, "completion_tokens": 6})]
104
+ elif "both" in said: # two calls, their deltas interleaved
105
+ events = [chunk({"role": "assistant", "content": None, "tool_calls": [call_delta(0, "call_a", "get_weather", "")]}),
106
+ chunk({"tool_calls": [call_delta(1, "call_b", "get_weather", "{\"city\": ")]}),
107
+ chunk({"tool_calls": [call_delta(0, None, None, "{\"city\": \"Paris\"}")]}),
108
+ chunk({"tool_calls": [call_delta(1, None, None, "\"Rome\"}")]}), chunk({}, "tool_calls")]
109
+ else:
110
+ events = [chunk({"role": "assistant", "content": ""}), chunk({"content": "Let me check."}),
111
+ chunk({"tool_calls": [call_delta(0, "call_1", "get_weather", "{\"ci")]}),
112
+ chunk({"tool_calls": [call_delta(0, None, None, "ty\": \"Paris\"}")]}),
113
+ chunk({}, "tool_calls"), chunk(None, None, usage={"prompt_tokens": 20, "completion_tokens": 9})]
114
+ events[-1]["choices"] = []
115
+ if model == "dies": # part of an answer, then the connection drops
116
+ events = events[:2]
117
+ for e in events:
118
+ self.wfile.write(f"data: {json.dumps(e)}\n\n".encode())
119
+ self.wfile.flush()
120
+ time.sleep(0.02)
121
+ if model != "dies":
122
+ self.wfile.write(b"data: [DONE]\n\n")
123
+
124
+
125
+ def write(name: str, data) -> None:
126
+ (TMP / "config" / name).write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8")
127
+
128
+
129
+ def sse_json(raw: str) -> list[dict]:
130
+ return [json.loads(line[6:]) for line in raw.splitlines() if line.startswith("data: {")]
131
+
132
+
133
+ def main() -> int:
134
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
135
+ up = ThreadingHTTPServer(("127.0.0.1", 0), Upstream)
136
+ threading.Thread(target=up.serve_forever, daemon=True).start()
137
+ write("endpoints.json", {"fake": {"base": f"http://127.0.0.1:{up.server_port}/v1", "key": "fake", "default": True,
138
+ "kind": "provider"},
139
+ "native": {"base": f"http://127.0.0.1:{up.server_port}/native/v1", "key": "fake",
140
+ "kind": "provider", "api": "anthropic"}})
141
+ import endpoints
142
+ agents = ("busy", "tooler", "slow", "hollow", "dies", "plain", "down", "down2", "prose", "pricey", "blind")
143
+ write("extra-openai-models.yaml", "".join(endpoints.yaml_block(a, a, "fake") for a in agents)
144
+ + endpoints.yaml_block("claude-native", "claude-native", "native"))
145
+ write("model-overrides.json", {**{a: {"tools": True, "vision": True, "max_output": 8192} for a in agents},
146
+ "prose": {"tools": False}, "blind": {"tools": True, "vision": False},
147
+ "claude-native": {"tools": True}})
148
+ write("model-roles.json", {"ad_hoc": ["pricey"]})
149
+ write("retired.json", {"gone": {"at": "2026-10-01"}})
150
+ write("collections.json", {
151
+ "agents": {"members": ["busy", "tooler"], "reply": "route", "description": "x"},
152
+ "slowpoke": {"members": ["slow", "tooler"], "description": "x"},
153
+ "hollow": {"members": ["hollow", "tooler"], "description": "x"},
154
+ "dies": {"members": ["dies", "tooler"], "description": "x"},
155
+ "plain": {"members": ["plain"], "description": "x"},
156
+ "broken": {"members": ["down", "down2"], "description": "x"},
157
+ "picky": {"members": ["gone", "pricey", "prose", "claude-native", "blind", "tooler"], "description": "x"},
158
+ "proseonly": {"members": ["prose"], "description": "x"},
159
+ "panel": {"members": ["tooler"], "description": "x"}})
160
+ import gateway
161
+ import serve
162
+ gateway.ROTATE, gateway.FIRST_TOKEN, gateway.ROUTE_GRACE, gateway.KEEPALIVE = 0, 1, 5.0, 0.3
163
+ gw = ThreadingHTTPServer(("127.0.0.1", 0), gateway.Handler)
164
+ threading.Thread(target=gw.serve_forever, daemon=True).start()
165
+
166
+ def post(path, body, **headers):
167
+ conn = http.client.HTTPConnection("127.0.0.1", gw.server_port, timeout=30)
168
+ conn.request("POST", path, json.dumps(body).encode(),
169
+ {"Host": f"127.0.0.1:{gw.server_port}", "Content-Type": "application/json", **headers})
170
+ r = conn.getresponse()
171
+ text = r.read().decode()
172
+ conn.close()
173
+ return r.status, dict(r.getheaders()), text
174
+
175
+ def ok(path, body):
176
+ status, headers, text = post(path, body)
177
+ assert status == 200, (path, status, text)
178
+ return headers, text
179
+
180
+ q = [{"role": "user", "content": "What is the weather in Paris?"}]
181
+ saved_err = sys.stderr
182
+ sys.stderr = open(os.devnull, "w", encoding="utf-8") # the access log
183
+ try:
184
+ # 1. OPENAI CHAT, STREAMED: the member's chunks relayed as they came, after a
185
+ # failover past a 429 that the caller never sees; the request's fields intact.
186
+ Upstream.seen.clear()
187
+ headers, raw = ok("/v1/chat/completions", {
188
+ "model": "bench/agents", "stream": True, "messages": q, "tools": [WEATHER], "tool_choice": "auto",
189
+ "parallel_tool_calls": False, "temperature": 0.1, "response_format": {"type": "text"},
190
+ "max_tokens": 50000, "seed": 7})
191
+ assert [b["model"] for b in Upstream.seen] == ["busy", "tooler"], Upstream.seen
192
+ sent = Upstream.seen[-1]
193
+ assert sent["tools"] == [WEATHER] and sent["tool_choice"] == "auto" and sent["parallel_tool_calls"] is False
194
+ assert sent["temperature"] == 0.1 and sent["seed"] == 7 and sent["response_format"] == {"type": "text"}
195
+ assert sent["max_tokens"] == 8192, sent # cut to the model's known maximum
196
+ assert sent["messages"] == q and sent["stream"] is True
197
+ got = sse_json(raw)
198
+ deltas = [c["choices"][0]["delta"].get("tool_calls") for c in got if c["choices"]]
199
+ assert [d for d in deltas if d] == [[call_delta(0, "call_1", "get_weather", "{\"ci")],
200
+ [call_delta(0, None, None, "ty\": \"Paris\"}")]], deltas
201
+ assert all(c["model"] == "bench/agents" for c in got) and raw.rstrip().endswith("data: [DONE]")
202
+ bench = got[-1]["bench"]
203
+ assert got[-1]["choices"] == [] and bench["alias"] == "tooler" and bench["reply"] == "route", bench
204
+ assert bench["failed_over"][0]["alias"] == "busy" and "429" in bench["failed_over"][0]["error"], bench
205
+ assert headers["X-Bench-Model"] == "tooler" and headers["X-Bench-Conversation"] == bench["conversation"]
206
+ conv = bench["conversation"]
207
+
208
+ # 2. STICKY: the next turn of that conversation goes straight to tooler
209
+ # (busy is cooling down anyway), and it is the same conversation.
210
+ Upstream.seen.clear()
211
+ turn2 = q + [{"role": "assistant", "content": None, "tool_calls": [
212
+ {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]},
213
+ {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}]
214
+ _, raw = ok("/v1/chat/completions", {"model": "bench/agents:route", "messages": turn2, "tools": [WEATHER]})
215
+ reply = json.loads(raw)
216
+ assert [b["model"] for b in Upstream.seen] == ["tooler"] and Upstream.seen[0]["messages"] == turn2
217
+ assert reply["choices"][0]["message"]["content"] == "It is 18C (sunny)." and reply["bench"]["conversation"] == conv
218
+ assert reply["choices"][0]["finish_reason"] == "stop" and reply["usage"]["completion_tokens"] == 6
219
+ assert reply["bench"]["turn"] == 2 and reply["bench"]["call"] == f"{conv}.1"
220
+ # Another conversation (another first message) is its own, picked afresh.
221
+ _, raw = ok("/v1/chat/completions", {"model": "bench/agents", "messages": [{"role": "user", "content": "Rome?"}],
222
+ "tools": [WEATHER]})
223
+ assert json.loads(raw)["bench"]["conversation"] != conv
224
+
225
+ # 3. OPENAI CHAT, WHOLE: the tool call assembled from its deltas.
226
+ _, raw = ok("/v1/chat/completions", {"model": "bench/slowpoke:route", "messages": q, "tools": [WEATHER]})
227
+ msg = json.loads(raw)["choices"][0]
228
+ assert msg["finish_reason"] == "tool_calls" and msg["message"]["content"] == "Let me check."
229
+ assert msg["message"]["tool_calls"] == [{"id": "call_1", "type": "function", "function": {
230
+ "name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}], msg
231
+ bench = json.loads(raw)["bench"] # slow sent nothing within FIRST_TOKEN: failed over
232
+ assert bench["alias"] == "tooler" and "nothing in 1s" in bench["failed_over"][0]["error"], bench
233
+
234
+ # 4. ANTHROPIC MESSAGES, as Claude Code sends them: tool_use out, tool_result back in.
235
+ claude = {"model": "bench/hollow:route", "max_tokens": 32000, "stream": True,
236
+ "system": [{"type": "text", "text": "You are an agent.", "cache_control": {"type": "ephemeral"}}],
237
+ "tools": [{"name": "get_weather", "description": "The weather in a city.",
238
+ "input_schema": WEATHER["function"]["parameters"]}],
239
+ "tool_choice": {"type": "auto"}, "messages": [{"role": "user", "content": q[0]["content"]}]}
240
+ Upstream.seen.clear()
241
+ _, raw = ok("/v1/messages", claude)
242
+ assert [b["model"] for b in Upstream.seen] == ["hollow", "tooler"] # an empty reply fails over too
243
+ assert Upstream.seen[-1]["tools"] == [WEATHER] and Upstream.seen[-1]["messages"][0] == {
244
+ "role": "system", "content": "You are an agent."}
245
+ events = sse_json(raw)
246
+ kinds = [e["type"] for e in events]
247
+ assert kinds[0] == "message_start" and kinds[-1] == "message_stop", kinds
248
+ blocks = [e["content_block"] for e in events if e["type"] == "content_block_start"]
249
+ assert blocks == [{"type": "text", "text": ""},
250
+ {"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {}}], blocks
251
+ assert json.loads(next(e["delta"]["partial_json"] for e in events if e["type"] == "content_block_delta"
252
+ and e["delta"]["type"] == "input_json_delta")) == {"city": "Paris"}
253
+ stop = next(e for e in events if e["type"] == "message_delta")
254
+ assert stop["delta"]["stop_reason"] == "tool_use" and stop["usage"] == {"input_tokens": 20, "output_tokens": 9}
255
+ assert stop["bench"]["alias"] == "tooler"
256
+ claude["messages"] += [{"role": "assistant", "content": [
257
+ {"type": "text", "text": "Let me check."},
258
+ {"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Paris"}}]},
259
+ {"role": "user", "content": [
260
+ {"type": "tool_result", "tool_use_id": "call_1", "content": [{"type": "text", "text": "rain"}]}]}]
261
+ Upstream.seen.clear()
262
+ _, raw = ok("/v1/messages", {**claude, "stream": False})
263
+ assert [b["model"] for b in Upstream.seen] == ["tooler"] # sticky, past hollow
264
+ assert Upstream.seen[0]["messages"][-2:] == [
265
+ {"role": "assistant", "content": "Let me check.", "tool_calls": [{"id": "call_1", "type": "function", "function": {
266
+ "name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]},
267
+ {"role": "tool", "tool_call_id": "call_1", "content": "rain"}], Upstream.seen[0]["messages"]
268
+ reply = json.loads(raw)
269
+ assert reply["content"] == [{"type": "text", "text": "It is 18C (rain)."}] and reply["stop_reason"] == "end_turn"
270
+ _, raw = ok("/v1/messages", {**claude, "stream": False, "messages": claude["messages"][:1]})
271
+ reply = json.loads(raw)
272
+ assert reply["stop_reason"] == "tool_use" and reply["content"][1] == {
273
+ "type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Paris"}}, reply
274
+
275
+ # 5. PARALLEL CALLS, deltas interleaved: each arrives whole outside OpenAI's format.
276
+ both = [{"role": "user", "content": "both cities"}]
277
+ _, raw = ok("/v1/messages", {**claude, "messages": both})
278
+ partial = [json.loads(e["delta"]["partial_json"]) for e in sse_json(raw)
279
+ if e.get("delta", {}).get("type") == "input_json_delta"]
280
+ assert partial == [{"city": "Paris"}, {"city": "Rome"}], partial
281
+ _, raw = ok("/v1/chat/completions", {"model": "bench/agents:route", "messages": both, "tools": [WEATHER]})
282
+ calls = json.loads(raw)["choices"][0]["message"]["tool_calls"]
283
+ assert [(c["id"], json.loads(c["function"]["arguments"])) for c in calls] == [
284
+ ("call_a", {"city": "Paris"}), ("call_b", {"city": "Rome"})], calls
285
+
286
+ # 6. RESPONSES (Codex): a function call out and its output back; a custom tool.
287
+ codex = {"model": "bench/agents:route", "instructions": "Be brief.", "stream": True, "input": q[0]["content"],
288
+ "tools": [{"type": "function", "name": "get_weather", "parameters": WEATHER["function"]["parameters"]},
289
+ {"type": "custom", "name": "apply_patch", "description": "Patch files.",
290
+ "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"}}]}
291
+ Upstream.seen.clear()
292
+ _, raw = ok("/v1/responses", codex)
293
+ tools = Upstream.seen[-1]["tools"]
294
+ assert tools[0] == WEATHER | {"function": {**WEATHER["function"], "description": ""}}, tools
295
+ assert tools[1]["function"]["parameters"]["required"] == ["input"] and "start: /.+/" in tools[1]["function"]["description"]
296
+ events = sse_json(raw)
297
+ done = next(e for e in events if e["type"] == "response.completed")["response"]
298
+ assert [i["type"] for i in done["output"]] == ["message", "function_call"], done["output"]
299
+ assert done["output"][1]["call_id"] == "call_1" and json.loads(done["output"][1]["arguments"]) == {"city": "Paris"}
300
+ assert any(e["type"] == "response.function_call_arguments.done" for e in events)
301
+ back = {**codex, "stream": False, "input": [
302
+ {"role": "user", "content": q[0]["content"]},
303
+ {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\": \"Paris\"}"},
304
+ {"type": "function_call_output", "call_id": "call_1", "output": "windy"}]}
305
+ _, raw = ok("/v1/responses", back)
306
+ assert Upstream.seen[-1]["messages"][-1] == {"role": "tool", "tool_call_id": "call_1", "content": "windy"}
307
+ assert F.responses_text(json.loads(raw)) == "It is 18C (windy)."
308
+ ask = F.responses_ask({"input": [{"type": "custom_tool_call", "call_id": "c9", "name": "apply_patch", "input": "*** x"}]})
309
+ assert ask["messages"][0]["tool_calls"][0]["function"]["arguments"] == "{\"input\": \"*** x\"}"
310
+ turn = F.Turn()
311
+ turn.feed(chunk({"tool_calls": [call_delta(0, "c9", "apply_patch", "{\"input\": \"*** y\"}")]}))
312
+ item = F.route_reply("responses", turn, "m", {}, 1, 0.1, custom=["apply_patch"])["output"][0]
313
+ assert item["type"] == "custom_tool_call" and item["input"] == "*** y" and item["call_id"] == "c9", item
314
+ # Turn's keying: an id that comes after the call's first piece is that call's; a new id on
315
+ # a reused index (as some compatibility layers send parallel calls) is a new call.
316
+ turn = F.Turn()
317
+ for d in ([call_delta(0, None, "get_weather", "")], [call_delta(0, "call_late", None, "{\"city\": ")],
318
+ [call_delta(0, None, None, "\"Oslo\"}")], [call_delta(0, "call_2", "get_weather", "{}")]):
319
+ turn.feed(chunk({"tool_calls": d}))
320
+ assert [(c["id"], c["name"], c["arguments"]) for c in turn.calls] == [
321
+ ("call_late", "get_weather", "{\"city\": \"Oslo\"}"), ("call_2", "get_weather", "{}")], turn.calls
322
+
323
+ # 7. GEMINI: a functionCall part out (whole, in the last chunk), its functionResponse back.
324
+ gem = {"contents": [{"role": "user", "parts": [{"text": q[0]["content"]}]}],
325
+ "tools": [{"functionDeclarations": [{"name": "get_weather", "parameters": {
326
+ "type": "OBJECT", "properties": {"city": {"type": "STRING"}}}}]}],
327
+ "toolConfig": {"functionCallingConfig": {"mode": "ANY"}}}
328
+ Upstream.seen.clear()
329
+ _, raw = ok("/v1beta/models/bench/agents:route:streamGenerateContent?alt=sse", gem)
330
+ assert Upstream.seen[-1]["tools"][0]["function"]["parameters"] == {
331
+ "type": "object", "properties": {"city": {"type": "string"}}} and Upstream.seen[-1]["tool_choice"] == "required"
332
+ last = sse_json(raw)[-1]["candidates"][0]
333
+ assert last["content"]["parts"] == [{"functionCall": {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}}}]
334
+ assert "".join(F.gemini_delta(c) for c in sse_json(raw)) == "Let me check."
335
+ gem["contents"] += [{"role": "model", "parts": [{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}]},
336
+ {"role": "user", "parts": [{"functionResponse": {"name": "get_weather", "response": {"sky": "grey"}}}]}]
337
+ _, raw = ok("/v1beta/models/bench/agents:route:generateContent", gem)
338
+ sent = Upstream.seen[-1]["messages"]
339
+ assert sent[-2]["tool_calls"][0]["id"] == sent[-1]["tool_call_id"] == "call_0", sent # numbered, matched
340
+ assert F.gemini_text(json.loads(raw)) == "It is 18C ({\"sky\": \"grey\"})."
341
+
342
+ # 8. OLLAMA: tool_calls with object arguments, a tool message back.
343
+ olla = {"model": "bench/agents:route", "messages": q, "tools": [WEATHER], "stream": False}
344
+ _, raw = ok("/api/chat", olla)
345
+ assert json.loads(raw)["message"]["tool_calls"] == [{"function": {"name": "get_weather", "arguments": {"city": "Paris"}}}]
346
+ _, raw = ok("/api/chat", {**olla, "stream": True})
347
+ lines = [json.loads(line) for line in raw.splitlines()]
348
+ assert any(line.get("message", {}).get("tool_calls") for line in lines) and lines[-1]["done"]
349
+ _, raw = ok("/api/chat", {**olla, "messages": q + [
350
+ {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "get_weather", "arguments": {"city": "Paris"}}}]},
351
+ {"role": "tool", "tool_name": "get_weather", "content": "hail"}]})
352
+ assert json.loads(raw)["message"]["content"] == "It is 18C (hail)."
353
+
354
+ # 9. A WHOLE JSON REPLY upstream becomes the one chunk it would have streamed.
355
+ _, raw = ok("/v1/messages", {**claude, "model": "bench/plain:route", "messages": both})
356
+ assert [json.loads(e["delta"]["partial_json"]) for e in sse_json(raw)
357
+ if e.get("delta", {}).get("type") == "input_json_delta"] == [{"city": "Oslo"}]
358
+
359
+ # 10. AFTER THE FIRST PIECE an error goes into the stream, in the format's shape.
360
+ _, raw = ok("/v1/messages", {**claude, "model": "bench/dies:route", "messages": [{"role": "user", "content": "x1"}]})
361
+ events = sse_json(raw)
362
+ assert events[-1]["type"] == "error" and "dies failed part way" in events[-1]["error"]["message"], events[-1]
363
+ _, raw = ok("/v1/chat/completions", {"model": "bench/dies:route", "stream": True,
364
+ "messages": [{"role": "user", "content": "x2"}]})
365
+ assert "dies failed part way" in sse_json(raw)[-1]["error"]["message"]
366
+
367
+ # 11. EVERY MEMBER FAILING keeps a status, streamed or not.
368
+ for stream in (False, True):
369
+ status, _, text = post("/v1/messages", {**claude, "model": "bench/broken:route", "stream": stream})
370
+ assert status == 502 and "down: Error: Error code: 503" in json.loads(text)["error"]["message"], text
371
+ status, _, text = post("/v1/chat/completions", {"model": "bench/agents:route", "messages": [
372
+ {"role": "user", "content": "x3"}]}) # busy cooling, tooler fine: still answers
373
+ assert status == 200, text
374
+
375
+ # 12. WHO MAY BE ROUTED TO: tools, images, ad hoc, retired, another API.
376
+ Upstream.seen.clear()
377
+ _, raw = ok("/v1/chat/completions", {"model": "bench/picky:route", "messages": [{"role": "user", "content": [
378
+ {"type": "text", "text": "what is this?"}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PIXEL}"}}]}],
379
+ "tools": [WEATHER]})
380
+ skipped = {k["alias"]: k["why"] for k in json.loads(raw)["bench"]["skipped"]}
381
+ assert [b["model"] for b in Upstream.seen] == ["tooler"], Upstream.seen
382
+ assert "retired" in skipped["gone"] and "ad hoc" in skipped["pricey"] and "Tool calling" not in skipped["prose"]
383
+ assert "does not call tools" in skipped["prose"] and "anthropic API" in skipped["claude-native"]
384
+ assert "does not take images" in skipped["blind"], skipped
385
+ status, _, text = post("/v1/chat/completions", {"model": "bench/proseonly:route", "messages": q, "tools": [WEATHER]})
386
+ assert status == 400 and "does not call tools" in json.loads(text)["error"]["message"], text
387
+ status, _, text = post("/v1/chat/completions", {"model": "bench/proseonly:route", "messages": q})
388
+ assert status == 200, text # no tools asked for: any member will do
389
+
390
+ # 13. A PANEL STILL REFUSES TOOLS, and says how to ask for route mode.
391
+ status, _, text = post("/v1/chat/completions", {"model": "bench/panel", "messages": q, "tools": [WEATHER]})
392
+ assert status == 400 and ":route" in json.loads(text)["error"]["message"], text
393
+ status, _, text = post("/v1/messages", {**claude, "model": "bench/panel:all"})
394
+ assert status == 400 and json.loads(text)["error"]["type"] == "invalid_request_error", text
395
+
396
+ # 14. RECORDED: the ledger, routes.jsonl per turn, one offload per conversation.
397
+ import ledger
398
+ import offload
399
+ rows = list(ledger.rows(0))
400
+ assert rows and all(r["purpose"] == "route" for r in rows), rows[:2]
401
+ assert any(r["alias"] == "busy" and r["status"] == "error" for r in rows)
402
+ assert any(r["alias"] == "tooler" and r["status"] == "ok" for r in rows) # a tool-call turn is an answer
403
+ lines = [json.loads(line) for line in (TMP / "runs" / "gateway" / "routes.jsonl").read_text(encoding="utf-8").splitlines()]
404
+ # (Gemini's and Ollama's turns above, with the same first message and
405
+ # no system text, joined the same conversation: the key is not the format.)
406
+ mine = [line for line in lines if line["conversation"] == conv]
407
+ assert [line["turn"] for line in mine[:2]] == [1, 2] and mine[0]["tool_calls"] == 1, mine
408
+ assert mine[0]["tried"][0]["alias"] == "busy", mine
409
+ assert mine[0]["tokens_out"] == 9 and "prompt" not in mine[0] and "messages" not in mine[0]
410
+ assert any(line["status"] == "error" and line["alias"] == "" for line in lines) # every member failed
411
+ entries = [e for e in offload.entries() if e.get("call", "").startswith(conv)]
412
+ assert len(entries) == 1 and entries[0]["call"] == f"{conv}.1" and entries[0]["useful"] is None, entries
413
+ assert entries[0]["task"] == "What is the weather in Paris? (bench/agents:route)", entries[0]
414
+ assert not (TMP / "runs" / "gateway" / "calls.jsonl").exists() # no panel rows: nothing Owed
415
+ card = serve.gateway_card()
416
+ assert "0 verdicts owed" in card and "Route conversations" in card and conv in card, card[-2000:]
417
+ offload.verdict(f"{conv}.1", True, "fixed the bug")
418
+ assert "tooler ✓" in serve.gateway_card()
419
+ finally:
420
+ sys.stderr = saved_err
421
+ gw.shutdown()
422
+ up.shutdown()
423
+ shutil.rmtree(TMP, ignore_errors=True)
424
+ print("route mode: tool round trips in every format, failover, stickiness and recording hold")
425
+ return 0
426
+
427
+
428
+ if __name__ == "__main__":
429
+ sys.exit(main())
@@ -358,8 +358,11 @@ def main() -> int:
358
358
  hold.wait(10)
359
359
  return outcome[self.served][0]
360
360
 
361
- def fake_spawn(served, endpoint="", who="ui"):
361
+ modes = {}
362
+
363
+ def fake_spawn(served, endpoint="", who="ui", mode="full"):
362
364
  order.append(served)
365
+ modes[served] = mode
363
366
  (runs / f"onboard-{served}.log").write_text(f"onboarding {served} from e -> alias {served}-x\n"
364
367
  + outcome[served][1], encoding="utf-8")
365
368
  return {"ok": True, "errors": [], "log": f"onboard-{served}.log"}, Proc(served)
@@ -371,7 +374,7 @@ def main() -> int:
371
374
  time.sleep(0.05)
372
375
 
373
376
  kept = (serve._spawn_onboard, serve._onboard_refusal, serve.DATA)
374
- serve._spawn_onboard, serve._onboard_refusal, serve.DATA = fake_spawn, lambda s, e: [], tmp
377
+ serve._spawn_onboard, serve._onboard_refusal, serve.DATA = fake_spawn, lambda s, e, m="full": [], tmp
375
378
  try:
376
379
  first = serve.queue_onboard([{"served": "a", "endpoint": "e"}, {"served": "ok", "endpoint": "e"}])
377
380
  twice = serve.queue_onboard([{"served": "ok", "endpoint": "e"}])["ok"]
@@ -394,7 +397,8 @@ def main() -> int:
394
397
  settle()
395
398
  stopped = {i["served"]: i["state"] for i in serve.onboard_queue()["items"]}
396
399
  order.remove("quiet")
397
- serve.queue_onboard([{"served": s, "endpoint": "e"} for s in ("ok", "quiet", "bad")])
400
+ serve.queue_onboard([{"served": s, "endpoint": "e", "mode": m} for s, m in
401
+ (("ok", "full"), ("quiet", "quick"), ("bad", "quick-adhoc"))])
398
402
  settle()
399
403
  batch = {i["served"]: (i["state"], i["alias"], bool(i["note"])) for i in serve.onboard_queue()["items"]}
400
404
  cleared = serve.clear_onboard_queue()["items"]
@@ -407,8 +411,9 @@ def main() -> int:
407
411
  and stopped == {"a": "done", "ok": "skipped", "quiet": "no answer"} and order == ["a", "ok", "quiet", "bad"]
408
412
  and batch == {"ok": ("done", "ok-x", False), "quiet": ("no answer", "quiet-x", True),
409
413
  "bad": ("failed", "bad-x", True)}
410
- and cleared == [] and bad_input == (False, False, False))
411
- print(("ok " if ok else "FAIL ") + "onboarding queue" + ("" if ok else f" -> {stopped} {order} {batch}"))
414
+ and cleared == [] and bad_input == (False, False, False)
415
+ and modes == {"a": "full", "ok": "full", "quiet": "quick", "bad": "quick-adhoc"})
416
+ print(("ok " if ok else "FAIL ") + "onboarding queue" + ("" if ok else f" -> {stopped} {order} {batch} {modes}"))
412
417
  failures += 0 if ok else 1
413
418
 
414
419
  # Dismiss: one picker key or many in one write, a change-log line each;
@@ -0,0 +1,79 @@
1
+ """server.json, the MCP Registry's entry: it says what the packages really are.
2
+
3
+ python scripts/test_server_json.py
4
+
5
+ A release publishes server.json to the registry after npm and PyPI
6
+ (.github/workflows/release.yml), and the registry refuses it, after the
7
+ packages are already out, when the entry and the packages disagree. So this
8
+ checks what the registry checks, and what it cannot: the name is in the
9
+ io.github.<owner> namespace GitHub's OIDC token proves (the owner as the
10
+ repository URL spells it, since the match is case-sensitive); every version is
11
+ VERSION; package.json carries the name as mcpName (npm's proof) and the README
12
+ PyPI shows carries `mcp-name: <name>` (PyPI's); the identifiers are the real
13
+ package names; the arguments start the MCP server through launcher.py; the
14
+ environment variables are the ones mcp_server.py reads. Standard library
15
+ only: the full schema is validated by mcp-publisher in the release itself.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import pathlib
22
+ import re
23
+ import sys
24
+ import tomllib
25
+
26
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
27
+ sys.path.insert(0, str(ROOT))
28
+ import launcher # noqa: E402
29
+
30
+ SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json"
31
+
32
+
33
+ def main() -> int:
34
+ server = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
35
+ npm = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
36
+ pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
37
+ version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
38
+
39
+ assert server["$schema"] == SCHEMA, server["$schema"]
40
+ name = server["name"]
41
+ owner = re.fullmatch(r"git\+https://github\.com/([^/]+)/[^/]+?\.git", npm["repository"]["url"]).group(1)
42
+ assert name == f"io.github.{owner}/benchwarmers", f"{name}: not in io.github.{owner}, the OIDC namespace"
43
+ assert re.fullmatch(r"[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+", name) and 3 <= len(name) <= 200, name
44
+ assert 1 <= len(server["description"]) <= 100, f"description is {len(server['description'])} chars, max 100"
45
+ assert 1 <= len(server.get("title", "x")) <= 100, server["title"]
46
+ assert server["repository"] == {"url": f"https://github.com/{owner}/benchwarmers", "source": "github"}
47
+
48
+ assert npm["version"] == version, f"package.json says {npm['version']}, VERSION says {version}"
49
+ assert server["version"] == version, f"server.json says {server['version']}, VERSION says {version}"
50
+ for p in server["packages"]:
51
+ assert p["version"] == version, f"server.json's {p['registryType']} package says {p['version']}"
52
+
53
+ # Ownership: npm's package.json and PyPI's README name the server.
54
+ assert npm.get("mcpName") == name, f"package.json mcpName is {npm.get('mcpName')!r}, not {name}"
55
+ assert pyproject["readme"] == "README.md", pyproject["readme"]
56
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
57
+ assert re.search(r"mcp-name: " + re.escape(name) + r"(?=\s|<|-->|$)", readme), \
58
+ f"README.md has no 'mcp-name: {name}' followed by a space, a newline or -->"
59
+
60
+ kinds = {p["registryType"]: p for p in server["packages"]}
61
+ assert sorted(kinds) == ["npm", "pypi"] and len(kinds) == len(server["packages"]), list(kinds)
62
+ assert kinds["npm"]["identifier"] == npm["name"], kinds["npm"]["identifier"]
63
+ assert kinds["pypi"]["identifier"] == pyproject["name"], kinds["pypi"]["identifier"]
64
+
65
+ read = set(re.findall(r'environ\.get\("(\w+)"', (ROOT / "mcp_server.py").read_text(encoding="utf-8")))
66
+ for kind, p in kinds.items():
67
+ assert p["transport"] == {"type": "stdio"}, (kind, p["transport"])
68
+ args = [a["value"] for a in p["packageArguments"] if a["type"] == "positional"]
69
+ assert launcher.plan(args).get("script") == "mcp_server.py", f"{kind}: {args} does not start mcp_server.py"
70
+ names = [e["name"] for e in p["environmentVariables"]]
71
+ assert set(names) == read and len(names) == len(read), f"{kind}: lists {names}, mcp_server.py reads {read}"
72
+ for e in p["environmentVariables"]:
73
+ assert e.get("isSecret", False) == (e["name"] == "BENCHWARMERS_KEY"), e
74
+ print(f"server.json ok: {name} {version}, npm and pypi")
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
@@ -88,7 +88,11 @@ def bench_field() -> None:
88
88
  assert "prompt" not in b and "system" not in b
89
89
  chunks = [json.loads(line[6:]) for line in post({**msg, "stream": True}).splitlines()
90
90
  if line.startswith("data: {")]
91
- assert "bench" in chunks[0] and "bench" not in chunks[1], chunks
91
+ # Seat by seat as each finishes; the bench rides on the last chunk, with the finish_reason.
92
+ assert "bench" in chunks[-1] and chunks[-1]["choices"][0]["finish_reason"] == "stop", chunks
93
+ assert not any("bench" in c for c in chunks[:-1]), chunks
94
+ said = "".join(c["choices"][0]["delta"].get("content", "") for c in chunks)
95
+ assert all(s["call"] in said for s in chunks[-1]["bench"]["seats"]), said
92
96
  srv.shutdown()
93
97
 
94
98
 
@@ -97,13 +97,14 @@ def main() -> int:
97
97
  write("model-overrides.json", {"eye": {"vision": True}, "eye2": {"vision": True}, "echo": {"vision": True},
98
98
  "blind": {"vision": False}, "planned": {"vision": True},
99
99
  "tiny": {"vision": True, "context_window": 3000}})
100
+ write("model-roles.json", {"models": {"eye2": "vision"}}) # a specialist: sits only for pictures
100
101
  write("gateway.json", {"max_calls": 5, "max_explorers": 2})
101
102
  write("collections.json", {
102
103
  "vision": {"members": ["eye", "blind", "unknown", "echo"], "description": "photos"},
103
104
  "merged": {"members": ["eye", "eye2"], "reply": "merged", "merger": "mg", "description": "x"},
104
105
  "blindonly": {"members": ["blind", "unknown", "planned"], "description": "x"},
105
106
  "small": {"members": ["tiny"], "description": "x"},
106
- "text": {"members": ["eye"], "description": "x"}})
107
+ "text": {"members": ["eye", "eye2"], "description": "x"}})
107
108
 
108
109
  import client
109
110
  import gateway
@@ -164,12 +165,14 @@ def main() -> int:
164
165
  where = [text[max(0, text.find(secret) - 300):text.find(secret) + 50] for text in shown if secret in text]
165
166
  assert not where, f"{secret!r} was written down: {where}"
166
167
 
167
- # 2. A text request takes the llm path, as before, with the same bench keys.
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.
168
170
  write("gateway.json", {"max_calls": 5, "max_explorers": 0}) # members only from here
169
171
  ASKED.clear()
170
172
  status, r = post({"model": "bench/text", "messages": [{"role": "user", "content": "say hi"}]})
171
173
  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"]
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)]
173
176
 
174
177
  # 3. Merged: the seats see the images; the merger gets text, placeholders and answers.
175
178
  LLM_CALLS.clear()