@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.
- package/AGENTS.md +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +170 -44
- package/ui/bench.js +6 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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())
|
package/scripts/test_verdict.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/scripts/test_vision.py
CHANGED
|
@@ -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()
|