@wendellresearch-ai/benchwarmers 0.2.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
package/scripts/check.py CHANGED
@@ -55,6 +55,8 @@ CHECKS = [
55
55
  ("scripts/test_merged.py", [], False),
56
56
  ("scripts/test_vision.py", [], False),
57
57
  ("scripts/test_formats.py", [], False),
58
+ ("scripts/test_route.py", [], False),
59
+ ("scripts/test_native.py", [], False),
58
60
  ("scripts/test_serve_edit.py", [], False),
59
61
  ("scripts/test_connect.py", [], False),
60
62
  ("scripts/test_auth.py", [], False),
@@ -179,7 +179,8 @@ def main() -> int:
179
179
  assert call(d, "/api/gateway-key", {"name": "other", "collections": ["nope"]}, **jar)[0] == 400
180
180
  assert call(d, "/api/gateway-key", {"name": "other", "rpm": "lots"}, **jar)[0] == 400
181
181
  status, _, text = call(g, "/v1/models", **estate)
182
- assert [m["id"] for m in json.loads(text)["data"]] == ["bench/vision", "bench/vision:merged"], text
182
+ assert [m["id"] for m in json.loads(text)["data"]] == ["bench/vision", "bench/vision:merged",
183
+ "bench/vision:route"], text
183
184
  ask = {"messages": [{"role": "user", "content": "hi"}]}
184
185
  status, _, text = call(g, "/v1/chat/completions", {"model": "bench/core", **ask}, **estate)
185
186
  assert status == 403 and "may call bench/vision" in text, text
@@ -202,20 +202,29 @@ def translators() -> None:
202
202
  "/v1/models", "/v1/responses", "/v1/completions")] == \
203
203
  ["anthropic", "ollama_chat", "gemini", "gemini", "chat", "responses", "completion"]
204
204
 
205
- # Refused, not dropped: tools, tool turns, a stored response, what no seat could read.
205
+ # Tools and tool turns are read (route mode passes them on) and marked
206
+ # agentic, which a panel refuses; scripts/test_route.py checks the
207
+ # translations. Tools only their own vendor runs are refused here.
206
208
  tool = [{"name": "f", "input_schema": {}}]
207
209
  user = [{"role": "user", "content": "hi"}]
208
- for fn, body in ((F.chat_ask, {"messages": user, "tools": [{"type": "function"}]}),
209
- (F.chat_ask, {"messages": user + [{"role": "tool", "content": "x"}]}),
210
+ for fn, body in ((F.chat_ask, {"messages": user, "tools": [{"type": "function", "function": {"name": "f"}}]}),
211
+ (F.chat_ask, {"messages": user + [{"role": "tool", "tool_call_id": "c", "content": "x"}]}),
210
212
  (F.anthropic_ask, {"messages": user, "tools": tool}),
211
213
  (F.anthropic_ask, {"messages": [{"role": "user", "content": [{"type": "tool_result"}]}]}),
212
- (F.responses_ask, {"input": "hi", "tools": [{"type": "web_search"}]}),
213
214
  (F.responses_ask, {"input": [{"type": "function_call_output", "output": "x"}]}),
214
- (F.ollama_chat_ask, {"messages": user, "tools": [{}]}),
215
215
  (F.ollama_chat_ask, {"messages": [{"role": "assistant", "tool_calls": [{}]}]})):
216
- refused(fn, body, said="route mode")
217
- refused(F.gemini_ask, {"contents": [], "tools": [{"functionDeclarations": []}]}, "m", said="route mode")
218
- refused(F.gemini_ask, {"contents": [{"parts": [{"functionCall": {}}]}]}, "m", said="route mode")
216
+ assert fn(body)["agentic"], (fn, body)
217
+ assert F.gemini_ask({"contents": [{"role": "model", "parts": [{"functionCall": {"name": "f"}}]}]}, "m")["agentic"]
218
+ refused(F.gemini_ask, {"contents": [{"parts": [{"functionCall": {"name": "f"}}]}]}, "m", said="model turn")
219
+ assert not F.chat_ask({"messages": user})["agentic"]
220
+ refused(F.responses_ask, {"input": "hi", "tools": [{"type": "web_search"}]}, said="OpenAI runs itself")
221
+ refused(F.anthropic_ask, {"messages": user, "tools": [{"type": "web_search_20250305", "name": "web_search"}]},
222
+ said="Anthropic runs")
223
+ refused(F.anthropic_ask, {"messages": [{"role": "assistant", "content": [{"type": "server_tool_use"}]}]},
224
+ said="Anthropic runs")
225
+ refused(F.gemini_ask, {"contents": [], "tools": [{"googleSearch": {}}]}, "m", said="Gemini runs")
226
+ refused(F.ollama_chat_ask, {"messages": user, "tools": [{}]}, said='"tools"')
227
+ refused(F.chat_ask, {"messages": user, "functions": [{}]}, said="legacy")
219
228
  refused(F.responses_ask, {"input": "hi", "previous_response_id": "resp_1"}, said="never stored")
220
229
  refused(F.gemini_ask, {"contents": [{"parts": [{"inlineData": {"mimeType": "application/pdf", "data": "x"}}]}]},
221
230
  "m", said="only images")
@@ -451,12 +460,12 @@ def gateway_routes() -> None:
451
460
  lambda e: e["error"]["message"]),
452
461
  ("/v1/messages", {"model": "bench/core", "max_tokens": 8, "tools": [{"name": "f"}],
453
462
  "messages": [{"role": "user", "content": user}]}, lambda e: e["error"]["message"]),
454
- ("/v1/responses", {"model": "bench/core", "input": user, "tools": [{"type": "function"}]},
463
+ ("/v1/responses", {"model": "bench/core", "input": user, "tools": [{"type": "function", "name": "f"}]},
455
464
  lambda e: e["error"]["message"]),
456
- ("/api/chat", {"model": "bench/core", "tools": [{}], "messages": [{"role": "user", "content": user}]},
457
- lambda e: e["error"]),
465
+ ("/api/chat", {"model": "bench/core", "tools": [{"type": "function", "function": {"name": "f"}}],
466
+ "messages": [{"role": "user", "content": user}]}, lambda e: e["error"]),
458
467
  ("/v1beta/models/bench/core:generateContent", {"contents": [{"parts": [{"text": user}]}],
459
- "tools": [{"functionDeclarations": [{}]}]},
468
+ "tools": [{"functionDeclarations": [{"name": "f"}]}]},
460
469
  lambda e: e["error"]["message"])):
461
470
  status, _, text, _ = call(path, body)
462
471
  assert status == 400 and "route mode" in msg(json.loads(text)), (path, status, text)
@@ -164,10 +164,10 @@ def main() -> int:
164
164
  assert texts[0] == "" and texts[1].startswith("answer from merger") and len(chunks) == 3, texts
165
165
  assert len(chunks[-1]["bench"]["seats"]) == 2
166
166
 
167
- # 6. /v1/models: each collection and its other reply mode.
167
+ # 6. /v1/models: each collection and its other reply modes.
168
168
  with urllib.request.urlopen(url + "/v1/models", timeout=10) as resp:
169
169
  listed = {m["id"]: m["reply"] for m in json.load(resp)["data"]}
170
- assert listed == {"bench/core": "merged", "bench/core:all": "all"}, listed
170
+ assert listed == {"bench/core": "merged", "bench/core:all": "all", "bench/core:route": "route"}, listed
171
171
 
172
172
  # 7. An empty merger: the member with the most useful verdicts on the shape,
173
173
  # else (no verdicts, and no suite scores for a router's primary) the first member.
@@ -0,0 +1,188 @@
1
+ """The built-in client on native Anthropic Messages and Gemini APIs, against fake servers.
2
+
3
+ python scripts/test_native.py
4
+
5
+ An endpoint whose `api` is `anthropic` or `gemini`: the request is built
6
+ by metrics/formats.py (system text apart, the model's own max_tokens, an
7
+ image as the API's own part), sent with the API's key header and streamed;
8
+ the reply is read into the client's usual result (text, reasoning, usage,
9
+ served model, finish) and its ledger line; an HTTP error and an error sent
10
+ mid-stream keep the error-text contract (`Error: Error code: 429 - ...`,
11
+ 529 for Anthropic's overloaded_error), a stream that breaks off is
12
+ `client failed`. Each endpoint's model list in its own shape, the
13
+ dashboard's Add form saving the choice, and such a model always going
14
+ through the built-in client. Temp data folder; no model is called.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import pathlib
22
+ import shutil
23
+ import sys
24
+ import tempfile
25
+ import threading
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+
28
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
29
+ TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-native-"))
30
+ (TMP / "config").mkdir()
31
+ (TMP / "llm").mkdir()
32
+ os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCH_CLIENT="llm",
33
+ BENCHWARMERS_KEY_ANT="sk-ant-test", BENCHWARMERS_KEY_GEM="gm-test")
34
+ sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
35
+
36
+ PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
37
+
38
+
39
+ def sse(events, named=False) -> bytes:
40
+ return b"".join(((f"event: {e['type']}\n" if named and "type" in e else "") + f"data: {json.dumps(e)}\n\n").encode()
41
+ for e in events)
42
+
43
+
44
+ class Native(BaseHTTPRequestHandler):
45
+ """Anthropic under /a/v1, Gemini under /g/v1beta; the model name picks the behaviour."""
46
+ seen: list[tuple[str, dict, dict]] = []
47
+
48
+ def log_message(self, *a):
49
+ pass
50
+
51
+ def _send(self, code: int, body: bytes, ctype: str = "application/json") -> None:
52
+ self.send_response(code)
53
+ self.send_header("Content-Type", ctype)
54
+ self.send_header("Content-Length", str(len(body)))
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ def do_GET(self): # noqa: N802
59
+ Native.seen.append((self.path, {k.lower(): v for k, v in self.headers.items()}, {}))
60
+ if self.path.startswith("/a/v1/models"):
61
+ return self._send(200, json.dumps({"data": [{"id": "claude-x", "type": "model"}]}).encode())
62
+ return self._send(200, json.dumps({"models": [
63
+ {"name": "models/gemini-x", "supportedGenerationMethods": ["generateContent", "countTokens"]},
64
+ {"name": "models/embed-x", "supportedGenerationMethods": ["embedContent"]}]}).encode())
65
+
66
+ def do_POST(self): # noqa: N802
67
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
68
+ Native.seen.append((self.path, {k.lower(): v for k, v in self.headers.items()}, body))
69
+ if self.path.startswith("/a/"):
70
+ model = body["model"]
71
+ if model == "claude-busy":
72
+ return self._send(429, json.dumps({"type": "error", "error": {
73
+ "type": "rate_limit_error", "message": "slow down"}}).encode())
74
+ events = [{"type": "message_start", "message": {"model": "claude-x-20261001", "usage": {"input_tokens": 12}}},
75
+ {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}},
76
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "hmm"}},
77
+ {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}},
78
+ {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "rea"}},
79
+ {"type": "ping"},
80
+ {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "dy"}},
81
+ {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 5}},
82
+ {"type": "message_stop"}]
83
+ if model == "claude-overloaded":
84
+ events = events[:5] + [{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}]
85
+ if model == "claude-cut":
86
+ events = events[:5]
87
+ return self._send(200, sse(events, named=True), "text/event-stream")
88
+ if "gemini-quota" in self.path:
89
+ return self._send(429, json.dumps({"error": {"code": 429, "message": "quota", "status": "RESOURCE_EXHAUSTED"}}).encode())
90
+ chunks = [{"candidates": [{"content": {"role": "model", "parts": [{"text": "thinking", "thought": True}]}}]},
91
+ {"candidates": [{"content": {"role": "model", "parts": [{"text": "rea"}]}}]},
92
+ {"candidates": [{"content": {"role": "model", "parts": [{"text": "dy"}]}, "finishReason": "STOP"}],
93
+ "modelVersion": "gemini-x", "usageMetadata": {"promptTokenCount": 7, "candidatesTokenCount": 2,
94
+ "thoughtsTokenCount": 3}}]
95
+ return self._send(200, sse(chunks), "text/event-stream")
96
+
97
+
98
+ def write(name: str, data) -> None:
99
+ (TMP / "config" / name).write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8")
100
+
101
+
102
+ def main() -> int:
103
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
104
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), Native)
105
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
106
+ base = f"http://127.0.0.1:{srv.server_port}"
107
+ write("endpoints.json", {"ant": {"base": f"{base}/a/v1", "key": "ant", "kind": "provider", "api": "anthropic",
108
+ "default": True},
109
+ "gem": {"base": f"{base}/g/v1beta", "key": "gem", "kind": "provider", "api": "gemini"}})
110
+ import endpoints
111
+ write("extra-openai-models.yaml", "".join(endpoints.yaml_block(a, a, "ant") for a in
112
+ ("claude-x", "claude-busy", "claude-overloaded", "claude-cut"))
113
+ + "".join(endpoints.yaml_block(a, a, "gem") for a in ("gemini-x", "gemini-quota")))
114
+ write("model-options.json", {"claude-x": {"max_tokens": 700, "temperature": 0.3}})
115
+ import client
116
+ import ledger
117
+ try:
118
+ # ANTHROPIC: the native request, its key header, the stream read back.
119
+ heard = []
120
+ res = client.chat("claude-x", "Say ready.", system="Be brief.", timeout=30,
121
+ on_delta=lambda kind, text: heard.append((kind, text)))
122
+ path, headers, body = Native.seen[-1]
123
+ assert path == "/a/v1/messages" and headers["x-api-key"] == "sk-ant-test" and "authorization" not in headers
124
+ assert headers["anthropic-version"] == client.ANTHROPIC_VERSION
125
+ assert body == {"model": "claude-x", "max_tokens": 700, "temperature": 0.3, "stream": True, "system": "Be brief.",
126
+ "messages": [{"role": "user", "content": "Say ready."}]}, body
127
+ assert (res["text"], res["reasoning"], res["model"], res["finish"], res["error"]) == \
128
+ ("ready", "hmm", "claude-x-20261001", "stop", ""), res
129
+ assert res["usage"] == {"input_tokens": 12, "output_tokens": 5} and heard[0] == ("reasoning", "hmm"), (res, heard)
130
+ row = list(ledger.rows(0))[-1]
131
+ assert (row["alias"], row["status"], row["tokens_in"], row["tokens_out"]) == ("claude-x", "ok", 12, 5), row
132
+ assert not row.get("swap") # a dated name is the same model
133
+ # An image goes as Anthropic's own base64 block.
134
+ client.chat("claude-x", "", timeout=30, messages=[{"role": "user", "content": [
135
+ {"type": "text", "text": "what?"}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{PIXEL}"}}]}])
136
+ assert Native.seen[-1][2]["messages"][0]["content"][1] == {
137
+ "type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PIXEL}}
138
+ # Errors keep the contract: a status, an error sent mid-stream, a cut stream.
139
+ busy = client.chat("claude-busy", "x", timeout=30, retries=0)
140
+ assert busy["error"].startswith("Error: Error code: 429 - ") and "rate_limit_error" in busy["error"], busy
141
+ over = client.chat("claude-overloaded", "x", timeout=30)
142
+ assert over["error"].startswith("Error: Error code: 529 - ") and "Overloaded" in over["error"], over
143
+ cut = client.chat("claude-cut", "x", timeout=30)
144
+ assert cut["error"].startswith("Error: client failed: the stream broke off"), cut
145
+ # Tools are not sent to a native API from here (route mode passes chat completions only).
146
+ refused = client.chat("claude-x", "x", timeout=30, body={"tools": []})
147
+ assert refused["error"].startswith("Error: client failed: ") and "chat completions only" in refused["error"]
148
+
149
+ # GEMINI: the native request at its own path, its key header, the stream read back.
150
+ res = client.chat("gemini-x", "Say ready.", system="Be brief.", timeout=30)
151
+ path, headers, body = Native.seen[-1]
152
+ assert path == "/g/v1beta/models/gemini-x:streamGenerateContent?alt=sse", path
153
+ assert headers["x-goog-api-key"] == "gm-test" and "authorization" not in headers
154
+ assert body == {"contents": [{"role": "user", "parts": [{"text": "Say ready."}]}],
155
+ "systemInstruction": {"parts": [{"text": "Be brief."}]}}, body
156
+ assert (res["text"], res["reasoning"], res["model"], res["finish"]) == ("ready", "thinking", "gemini-x", "stop")
157
+ assert res["usage"] == {"prompt_tokens": 7, "completion_tokens": 2,
158
+ "completion_tokens_details": {"reasoning_tokens": 3}}, res
159
+ row = list(ledger.rows(0))[-1]
160
+ assert (row["tokens_in"], row["tokens_out"], row["reasoning"]) == (7, 2, 3), row
161
+ quota = client.chat("gemini-quota", "x", timeout=30, retries=0)
162
+ assert quota["error"].startswith("Error: Error code: 429 - ") and "RESOURCE_EXHAUSTED" in quota["error"], quota
163
+
164
+ # Each one's model list in its own shape, asked with its own key header.
165
+ assert list(endpoints.models("ant")) == ["claude-x"] and Native.seen[-1][1]["x-api-key"] == "sk-ant-test"
166
+ assert list(endpoints.models("gem")) == ["gemini-x"] # generateContent models only
167
+ assert Native.seen[-1][0] == "/g/v1beta/models?pageSize=1000"
168
+ # Neither goes through `llm -m`, whatever BENCH_CLIENT says.
169
+ assert client.backend("claude-x") == client.backend("gemini-x") == "builtin" and client.backend() == "llm"
170
+
171
+ # The dashboard's Add form: tested with the API's own key header, saved with the choice.
172
+ import serve
173
+ got = serve.save_endpoint("ant2", f"{base}/a/v1", "sk-ant-2", new=True, api="anthropic", test_only=True)
174
+ # (seen[-1] is the form's second ask, without a key, to say whether the list proves it.)
175
+ assert got["ok"] and got["served"] == 1 and Native.seen[-2][1]["x-api-key"] == "sk-ant-2", got
176
+ assert serve.save_endpoint("ant2", f"{base}/a/v1", "sk-ant-2", new=True, api="anthropic")["ok"]
177
+ assert endpoints.load()["ant2"]["api"] == "anthropic"
178
+ assert not serve.save_endpoint("x2", f"{base}/a/v1", "k", new=True, api="responses")["ok"] # Connect's only
179
+ assert "<select id='e-api'>" in serve.editor()
180
+ finally:
181
+ srv.shutdown()
182
+ shutil.rmtree(TMP, ignore_errors=True)
183
+ print("native Anthropic and Gemini: requests, streams, usage, errors and the Add form hold")
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())