@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.
- package/AGENTS.md +11 -8
- package/GATEWAY.md +169 -12
- package/PRIVACY.md +7 -5
- package/README.md +81 -37
- package/SECURITY.md +5 -4
- package/USAGE.md +37 -5
- package/VERSION +1 -1
- package/cli.py +1 -1
- package/gateway.py +352 -10
- package/metrics/bench_collections.py +7 -3
- package/metrics/client.py +312 -88
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +658 -95
- package/package.json +1 -1
- package/scripts/check.py +2 -0
- package/scripts/test_auth.py +2 -1
- package/scripts/test_formats.py +21 -12
- package/scripts/test_merged.py +2 -2
- package/scripts/test_native.py +188 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +2 -2
- package/serve.py +66 -8
- package/ui/bench.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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),
|
package/scripts/test_auth.py
CHANGED
|
@@ -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"
|
|
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
|
package/scripts/test_formats.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
refused(F.gemini_ask, {"contents": [{"parts": [{"functionCall": {}}]}]}, "m", said="
|
|
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": [{
|
|
457
|
-
|
|
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)
|
package/scripts/test_merged.py
CHANGED
|
@@ -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
|
|
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())
|