@wendellresearch-ai/benchwarmers 0.2.2 → 0.3.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.
@@ -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())
@@ -291,7 +291,7 @@ def main() -> int:
291
291
  model_client.CHOICE = cfg / "client.json"
292
292
  env, real_which = os.environ.pop("BENCH_CLIENT", None), shutil.which
293
293
  try:
294
- shutil.which = lambda name: "/usr/bin/" + name # llm installed: the default is llm
294
+ shutil.which = lambda name: "/usr/bin/" + name # llm installed: still built-in
295
295
  first = model_client.chosen()
296
296
  shutil.which = lambda name: None # not installed (npm, a container)
297
297
  bare = model_client.chosen()
@@ -310,7 +310,7 @@ def main() -> int:
310
310
  os.environ.pop("BENCH_CLIENT", None)
311
311
  if env is not None:
312
312
  os.environ["BENCH_CLIENT"] = env
313
- ok = (first == ("llm", "default") and bare == ("builtin", "default") and made["ok"] and picked == (("builtin", "settings"), "builtin")
313
+ ok = (first == ("builtin", "default") and bare == ("builtin", "default") and made["ok"] and picked == (("builtin", "settings"), "builtin")
314
314
  and pinned == (("llm", "environment"), False) and not no_llm and not unknown)
315
315
  print(("ok " if ok else "FAIL ") + "model client choice" + ("" if ok else f" -> {first} {made} {picked} {pinned}"))
316
316
  failures += 0 if ok else 1
package/serve.py CHANGED
@@ -963,7 +963,7 @@ def free_name(stem: str) -> str:
963
963
 
964
964
  def save_endpoint(name: str, base: str, secret: str, who: str = "ui", extra: dict | None = None,
965
965
  new: bool = False, kind: str = "", hub: str = "", admin: str = "",
966
- test_only: bool = False) -> dict:
966
+ test_only: bool = False, api: str = "") -> dict:
967
967
  """Add an endpoint, or change one's key. Tested before anything is saved;
968
968
  `test_only` (the form's Test button) stops there and saves nothing.
969
969
 
@@ -980,11 +980,16 @@ def save_endpoint(name: str, base: str, secret: str, who: str = "ui", extra: dic
980
980
  key where it is not the API key (LiteLLM's master key, CLI Proxy API's
981
981
  management key), is kept beside it under endpoints.admin_name; nothing
982
982
  tests it here, since a refused CLI Proxy API key counts toward a ban.
983
+ `api` (endpoints.APIS), on a new endpoint: what it speaks, when not chat
984
+ completions; Anthropic's and Gemini's native APIs take their own key
985
+ header, which the test sends too.
983
986
  """
984
987
  name, base, secret = (name or "").strip().lower(), (base or "").strip().rstrip("/"), (secret or "").strip()
985
- kind, hub, admin = (kind or "").strip(), (hub or "").strip(), (admin or "").strip()
988
+ kind, hub, admin, api = (kind or "").strip(), (hub or "").strip(), (admin or "").strip(), (api or "").strip()
986
989
  data = endpoints.load()
987
990
  errors = []
991
+ if api and (api not in endpoints.APIS or api == "responses"): # responses: Connect sets it, with its sign-in
992
+ errors.append(f"api: one of {', '.join(a for a in endpoints.APIS if a != 'responses')}")
988
993
  if kind and kind not in endpoints.KINDS:
989
994
  errors.append(f"kind: one of {', '.join(endpoints.KINDS)}")
990
995
  elif kind == "hub" and hub not in endpoints.HUBS:
@@ -1011,6 +1016,8 @@ def save_endpoint(name: str, base: str, secret: str, who: str = "ui", extra: dic
1011
1016
  # with its stale refresh token and overwrite the typed key.
1012
1017
  entry.pop("connect", None)
1013
1018
  entry.update(extra, base=base)
1019
+ if api and new:
1020
+ entry.pop("api", None) if api == "chat" else entry.update(api=api)
1014
1021
  if kind:
1015
1022
  entry.pop("hub", None)
1016
1023
  entry.update(kind=kind, **({"hub": hub} if kind == "hub" else {}))
@@ -2580,7 +2587,49 @@ def gateway_card(limit: int = 30) -> str:
2580
2587
  </dl>"""
2581
2588
  return card("Gateway", f"The last {len(records)} bench-gateway requests:"
2582
2589
  f" {seats_total} seats, {owed} verdict{'s' * (owed != 1)} owed.",
2583
- "".join(body), key)
2590
+ "".join(body) + route_table(judged), key)
2591
+
2592
+
2593
+ def route_table(judged: dict, limit: int = 30) -> str:
2594
+ """Route-mode conversations (runs/gateway/routes.jsonl, a line a turn),
2595
+ one row each, apart from the panels: an agent turn is not a seat, and a
2596
+ conversation's verdict is optional, so none of this counts as Owed.
2597
+ ponytail: reads the whole log; read its tail once it grows large."""
2598
+ convs: dict[str, dict] = {}
2599
+ for line in read(DATA / "runs" / "gateway" / "routes.jsonl").splitlines():
2600
+ try:
2601
+ t = json.loads(line)
2602
+ except ValueError:
2603
+ continue
2604
+ c = convs.setdefault(str(t.get("conversation", "")), {
2605
+ "at": t.get("at", ""), "model": t.get("model", ""), "key": t.get("key", ""), "task": t.get("task", ""),
2606
+ "turns": 0, "tools": 0, "failovers": 0, "models": {}})
2607
+ c["turns"] += 1
2608
+ c["tools"] += t.get("tool_calls") or 0
2609
+ c["failovers"] += len(t.get("tried") or [])
2610
+ c.update(last=t.get("at", ""), status=t.get("status", ""), error=t.get("error", ""))
2611
+ if t.get("alias"):
2612
+ c["models"].setdefault(t["alias"], t.get("call", ""))
2613
+ if not convs:
2614
+ return ""
2615
+ rows = ["<h3>Route conversations</h3><table><thead><tr>", th("When"), th("Collection"), th("Asked by"),
2616
+ th("First message"), th("Answered by"), th("Turns", True), th("Tool calls", True),
2617
+ th("Failovers", True), th("Last turn"), "</tr></thead><tbody>"]
2618
+ for conv, c in sorted(convs.items(), key=lambda kv: kv[1]["last"], reverse=True)[:limit]:
2619
+ chips = " ".join(f"<span class='chip' title='{esc(call + ' · ' + {True: 'useful', False: 'not useful', None: 'no verdict (optional)'}[judged.get(call)])}'>"
2620
+ f"{esc(alias)}{ {True: ' ✓', False: ' ✗', None: ''}[judged.get(call)]}</span>"
2621
+ for alias, call in c["models"].items())
2622
+ css = {"ok": "ok", "empty": "warn", "error": "err"}.get(c["status"], "")
2623
+ rows.append(
2624
+ f"<tr><td class='mono' data-sort='{esc(c['at'])}' title='{esc(conv)}'>{esc(c['at'][:16].replace('T', ' '))}</td>"
2625
+ f"<td class='mono'>{esc(c['model'])}</td><td class='mono'>{esc(c['key'] or '—')}</td>"
2626
+ f"<td>{esc(c['task'][:60])}</td><td>{chips or '—'}</td><td class='num'>{c['turns']}</td>"
2627
+ f"<td class='num'>{c['tools']}</td><td class='num{' muted' if not c['failovers'] else ''}'>{c['failovers']}</td>"
2628
+ f"<td><span class='chip {css}' title='{esc(c['error'])}'>{esc(c['status'])}</span></td></tr>")
2629
+ rows.append("</tbody></table><p class='muted'>Each conversation is one unjudged offload per model that"
2630
+ " answered it, under the call id on its chip; a verdict is optional:"
2631
+ " <code>python metrics/offload.py verdict &lt;call id&gt; --useful</code>.</p>")
2632
+ return "".join(rows)
2584
2633
 
2585
2634
 
2586
2635
  def gateway_seat(call: str) -> dict:
@@ -2791,11 +2840,13 @@ def collections_tab() -> str:
2791
2840
  "<p class='rules'>Which models may fill the explorer seats. Automatic asks a decision"
2792
2841
  " model to label each prompt when one is onboarded, else general.</p>"
2793
2842
  "<label class='field' for='c-reply'>Reply</label><select id='c-reply'>"
2794
- "<option value='all'>every answer</option><option value='merged'>merged</option></select>"
2843
+ "<option value='all'>every answer</option><option value='merged'>merged</option>"
2844
+ "<option value='route'>route (one member, for agents)</option></select>"
2795
2845
  "<p class='rules'>Merged: after the panel, one model writes a single answer saying where the"
2796
2846
  " seats agreed and who said what, for a chat UI that shows one answer; every seat is still"
2797
- " called and recorded. A caller can ask for either with <code>bench/&lt;name&gt;:merged</code>"
2798
- " or <code>:all</code>.</p>"
2847
+ " called and recorded. Route: no panel; one member, picked by its record, gets the request"
2848
+ " with its tools, for an agent such as Claude Code or Codex. A caller can ask for any with"
2849
+ " <code>bench/&lt;name&gt;:merged</code>, <code>:all</code> or <code>:route</code>.</p>"
2799
2850
  # Only a merged reply has a merger; the choice is kept while hidden.
2800
2851
  "<div id='c-merger-row' hidden><label class='field' for='c-merger'>Merger</label>"
2801
2852
  "<select id='c-merger'></select>"
@@ -3615,7 +3666,14 @@ def editor() -> str:
3615
3666
  + "</select><p class='rules'>Open WebUI's base is its <code>/api</code>, e.g."
3616
3667
  " <code>http://localhost:3000/api</code>, with an admin's API key. LiteLLM's and CLI Proxy"
3617
3668
  " API's are their <code>/v1</code>, e.g. <code>http://localhost:4000/v1</code> and"
3618
- " <code>http://localhost:8317/v1</code>.</p></div></div>"
3669
+ " <code>http://localhost:8317/v1</code>.</p></div>"
3670
+ # What it speaks (endpoints.APIS): the built-in client builds the native requests.
3671
+ "<label class='field' for='e-api'>API</label><select id='e-api'>"
3672
+ + "".join(f"<option value='{k}'>{esc(label)}</option>" for k, label in endpoints.APIS.items() if k != "responses")
3673
+ + "</select><p class='rules'>Chat completions unless the provider has none for your key. Native"
3674
+ " Anthropic's base is <code>https://api.anthropic.com/v1</code>, Gemini's"
3675
+ " <code>https://generativelanguage.googleapis.com/v1beta</code>; their models are called by the"
3676
+ " built-in client, and route mode does not pick them.</p></div>"
3619
3677
  "<label class='field' for='e-key'>API key</label>"
3620
3678
  "<input id='e-key' type='password' autocomplete='off' placeholder='leave blank to keep the current one'>"
3621
3679
  "<p class='rules'>Stored where <code>llm keys set</code> stores it, never in the"
@@ -3833,7 +3891,7 @@ class Handler(gateway.Handler):
3833
3891
  str(p.get("key", "")), new=bool(p.get("new")),
3834
3892
  kind=str(p.get("kind") or ""), hub=str(p.get("hub") or ""),
3835
3893
  admin=str(p.get("admin") or ""),
3836
- test_only=bool(p.get("test"))),
3894
+ test_only=bool(p.get("test")), api=str(p.get("api") or "")),
3837
3895
  "/api/endpoint/remove": lambda p: remove_endpoint(str(p.get("name", "")),
3838
3896
  with_models=bool(p.get("with_models"))),
3839
3897
  "/api/connect/start": lambda p: connect_start(str(p.get("provider") or ""),
package/ui/bench.js CHANGED
@@ -1093,7 +1093,9 @@
1093
1093
  function keyBody() {
1094
1094
  var body = {name: e.name.value, base: e.base.value, key: e.key.value, new: e.adding,
1095
1095
  admin: $('e-admin-row').hidden ? '' : e.admin.value};
1096
- if (e.adding) { body.kind = e.kind.value; body.hub = e.kind.value === 'hub' ? e.hub.value : ''; } // Change key keeps its kind
1096
+ if (e.adding) { // Change key keeps its kind and API
1097
+ body.kind = e.kind.value; body.hub = e.kind.value === 'hub' ? e.hub.value : ''; body.api = $('e-api').value;
1098
+ }
1097
1099
  return body;
1098
1100
  }
1099
1101
  $('e-add').addEventListener('click', function () { openEndpoint('add'); });