@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
|
@@ -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:
|
|
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 == ("
|
|
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 <call id> --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
|
|
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.
|
|
2798
|
-
" or
|
|
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/<name>: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
|
|
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) {
|
|
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'); });
|