@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.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 +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -373
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
|
@@ -1,284 +1,284 @@
|
|
|
1
|
-
"""The bench method as an OpenAI-compatible endpoint, at http://127.0.0.1:8123/v1.
|
|
2
|
-
|
|
3
|
-
python gateway.py
|
|
4
|
-
|
|
5
|
-
Ask for a COLLECTION by name — `bench/core`, `bench/reviewers` — and the prompt
|
|
6
|
-
goes in parallel to every member of it plus explorer seats `route.py` fills by
|
|
7
|
-
staleness, so the roster keeps getting evidence even when the same collection
|
|
8
|
-
is called every day:
|
|
9
|
-
|
|
10
|
-
explorers = min(max_explorers, max_calls - members) # defaults 2 and 5
|
|
11
|
-
|
|
12
|
-
Collections and those limits live in `config/collections.json` and
|
|
13
|
-
`config/gateway.json` (see `metrics/bench_collections.py`), edited from the
|
|
14
|
-
dashboard. A request with no model runs the default collection; a model name
|
|
15
|
-
that is not a collection is refused with the list, so a typo never runs one.
|
|
16
|
-
|
|
17
|
-
The reply is every answer, each labelled by seat and model with a call id,
|
|
18
|
-
including seats that failed or came back empty: who answered is evidence about
|
|
19
|
-
the roster, and a summary naming only the good answer hides it.
|
|
20
|
-
|
|
21
|
-
A collection's `shape` (review, design, …) decides which models may fill the
|
|
22
|
-
explorer seats. Left empty, a decision model labels the prompt when one that
|
|
23
|
-
takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
|
|
24
|
-
the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
|
|
25
|
-
model in an explorer chair, as `route.py --with` does.
|
|
26
|
-
|
|
27
|
-
THE LOOP
|
|
28
|
-
--------
|
|
29
|
-
Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
|
|
30
|
-
moment it answers, so the explorer seats rotate even when nobody judges. The
|
|
31
|
-
caller then checks the answers against the real code — agreement between seats
|
|
32
|
-
is not a verdict, they fail together — and records one per call id:
|
|
33
|
-
|
|
34
|
-
python metrics/offload.py verdict <call id> --useful --note "..."
|
|
35
|
-
POST /bench/verdict {"call": "<call id>", "useful": true, "note": "..."}
|
|
36
|
-
|
|
37
|
-
The verdict replaces the unjudged entry, and route.py's evidence — which
|
|
38
|
-
explorers are due, which models earn a place in a collection — reads it.
|
|
39
|
-
|
|
40
|
-
WHAT IT IS NOT
|
|
41
|
-
--------------
|
|
42
|
-
A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
|
|
43
|
-
exists only for the multi-model work. Seats are called with `llm -m`, exactly
|
|
44
|
-
as offloads always were, so endpoints, headers and per-model options all come
|
|
45
|
-
from the same registry. Full prompts and answers go to `runs/gateway/`,
|
|
46
|
-
gitignored. Bound to 127.0.0.1, like the dashboard.
|
|
47
|
-
"""
|
|
48
|
-
|
|
49
|
-
from __future__ import annotations
|
|
50
|
-
|
|
51
|
-
import datetime as dt
|
|
52
|
-
import json
|
|
53
|
-
import os
|
|
54
|
-
import pathlib
|
|
55
|
-
import secrets
|
|
56
|
-
import subprocess
|
|
57
|
-
import sys
|
|
58
|
-
import time
|
|
59
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
60
|
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
61
|
-
|
|
62
|
-
ROOT = pathlib.Path(__file__).resolve().parent
|
|
63
|
-
PORT = 8123
|
|
64
|
-
LOG_DIR = ROOT / "runs" / "gateway"
|
|
65
|
-
SEAT_TIMEOUT = 900
|
|
66
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
67
|
-
sys.path.insert(0, str(ROOT / "suite"))
|
|
68
|
-
import bench_collections # noqa: E402
|
|
69
|
-
import decide # noqa: E402
|
|
70
|
-
import offload # noqa: E402
|
|
71
|
-
import route # noqa: E402
|
|
72
|
-
|
|
73
|
-
# What the shape picker is asked to choose between. Each option carries a
|
|
74
|
-
# description because bare names mislead it: Mercury Decide called "list every
|
|
75
|
-
# place serve.py writes a file" `code` at 0.93 when given names alone.
|
|
76
|
-
SHAPES = {
|
|
77
|
-
"general": "A general question or task that fits none of the other kinds.",
|
|
78
|
-
"design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
|
|
79
|
-
"prose": "Writing or editing documentation or other text.",
|
|
80
|
-
"review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
|
|
81
|
-
"code": "Writing new code or changing existing code.",
|
|
82
|
-
"enumeration": "Listing every place something happens in given code or files.",
|
|
83
|
-
"quick": "A short, bounded task: classify, extract or summarise a small input.",
|
|
84
|
-
}
|
|
85
|
-
CONFIDENT = 0.6
|
|
86
|
-
LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def flatten(messages: list[dict]) -> tuple[str, str]:
|
|
90
|
-
"""(system, prompt) for `llm`. A panel question is one prompt; a longer
|
|
91
|
-
conversation is kept as labelled turns rather than silently truncated."""
|
|
92
|
-
def text(m):
|
|
93
|
-
c = m.get("content") or ""
|
|
94
|
-
return c if isinstance(c, str) else "\n".join(
|
|
95
|
-
p.get("text", "") for p in c if isinstance(p, dict))
|
|
96
|
-
# Newer OpenAI SDKs send "developer" where older ones send "system".
|
|
97
|
-
lead = ("system", "developer")
|
|
98
|
-
system = "\n\n".join(text(m) for m in messages if m.get("role") in lead)
|
|
99
|
-
turns = [m for m in messages if m.get("role") not in lead]
|
|
100
|
-
if len(turns) == 1:
|
|
101
|
-
return system, text(turns[0])
|
|
102
|
-
return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def pick_shape(prompt: str) -> tuple[str, str]:
|
|
106
|
-
"""(shape, how it was chosen). Falls back to general on any doubt."""
|
|
107
|
-
held = set(json.loads((ROOT / "config" / "model-roles.json").read_text(encoding="utf-8"))
|
|
108
|
-
.get("ad_hoc", []))
|
|
109
|
-
picker = next((a for a, d in decide.load().items()
|
|
110
|
-
if "choice" in d["question_types"] and a not in held), None)
|
|
111
|
-
if not picker:
|
|
112
|
-
return "general", "no decision model onboarded"
|
|
113
|
-
question = {"shape": {"type": "choice",
|
|
114
|
-
"instructions": "What kind of task does this prompt ask for?",
|
|
115
|
-
"criteria": SHAPES}}
|
|
116
|
-
try:
|
|
117
|
-
answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
|
|
118
|
-
except Exception as exc: # noqa: BLE001
|
|
119
|
-
return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
|
|
120
|
-
conf = answer.get("confidence", 0)
|
|
121
|
-
if answer.get("choice") not in SHAPES or conf < CONFIDENT:
|
|
122
|
-
return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
|
|
123
|
-
return answer["choice"], f"{picker}, {conf:.2f}"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def ask_seat(alias: str, system: str, prompt: str) -> dict:
|
|
127
|
-
cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
|
|
128
|
-
started = time.monotonic()
|
|
129
|
-
try:
|
|
130
|
-
done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
|
|
131
|
-
encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
|
|
132
|
-
env={**os.environ, "PYTHONIOENCODING": "utf-8"})
|
|
133
|
-
answer = done.stdout.strip()
|
|
134
|
-
# A non-zero exit is an error even when stderr is silent.
|
|
135
|
-
error = (done.stderr.strip() or f"llm exited {done.returncode}") if done.returncode else ""
|
|
136
|
-
except subprocess.TimeoutExpired:
|
|
137
|
-
answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
|
|
138
|
-
except Exception as exc: # noqa: BLE001
|
|
139
|
-
# One seat failing to start must not lose the other seats' records.
|
|
140
|
-
answer, error = "", f"{type(exc).__name__}: {exc}"
|
|
141
|
-
status = "error" if error else ("empty" if not answer else "ok")
|
|
142
|
-
return {"status": status, "answer": answer, "error": error[-500:],
|
|
143
|
-
"seconds": round(time.monotonic() - started, 1)}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None) -> str:
|
|
147
|
-
system, prompt = flatten(messages)
|
|
148
|
-
if not prompt.strip():
|
|
149
|
-
raise ValueError("no user message to put to the collection")
|
|
150
|
-
collection = bench_collections.load()[name]
|
|
151
|
-
members = list(collection.get("members") or [])
|
|
152
|
-
n_explorers = bench_collections.explorers_for(len(members))
|
|
153
|
-
# The system text goes to every seat too, so it counts for shape and size.
|
|
154
|
-
whole = f"{system}\n\n{prompt}".strip()
|
|
155
|
-
if not n_explorers:
|
|
156
|
-
shape, how = collection.get("shape") or "general", "no explorer seats to fill"
|
|
157
|
-
elif collection.get("shape"):
|
|
158
|
-
shape, how = collection["shape"], "set on the collection"
|
|
159
|
-
else:
|
|
160
|
-
shape, how = pick_shape(whole)
|
|
161
|
-
seats = [(f"member{i}", "member", alias) for i, alias in enumerate(members, 1)]
|
|
162
|
-
if n_explorers:
|
|
163
|
-
# `with` in the request body is route.py's --with: an explorer seat.
|
|
164
|
-
p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
|
|
165
|
-
members=members, explorers=n_explorers)
|
|
166
|
-
seats += [(key, label, p[key]["model"]) for key, label in
|
|
167
|
-
(("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
|
|
168
|
-
if not seats:
|
|
169
|
-
raise LookupError(f"collection {name!r} has no members and no explorer could be seated")
|
|
170
|
-
panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
|
|
171
|
-
model = bench_collections.PREFIX + name
|
|
172
|
-
with ThreadPoolExecutor(max_workers=len(seats)) as pool:
|
|
173
|
-
results = list(pool.map(lambda s: ask_seat(s[2], system, prompt), seats))
|
|
174
|
-
|
|
175
|
-
task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
|
|
176
|
-
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
177
|
-
record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
178
|
-
"model": model, "shape": shape, "shape_by": how, "system": system,
|
|
179
|
-
"prompt": prompt, "seats": []}
|
|
180
|
-
out = [f"Bench {model} {panel_id} · {len(members)} members + {len(seats) - len(members)}"
|
|
181
|
-
f" explorers · explorer shape {shape} ({how})\n"]
|
|
182
|
-
for (key, label, alias), r in zip(seats, results):
|
|
183
|
-
call = f"{panel_id}.{key}"
|
|
184
|
-
offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
|
|
185
|
-
+ (f": {r['error'][:120]}" if r["error"] else ""), call)
|
|
186
|
-
record["seats"].append({"call": call, "seat": label, "alias": alias, **r})
|
|
187
|
-
body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
|
|
188
|
-
"_empty reply — usually a token budget, not incapacity_")
|
|
189
|
-
out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
|
|
190
|
-
with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
|
|
191
|
-
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
192
|
-
out.append("---\nRecord a verdict per seat once checked against the code:\n"
|
|
193
|
-
"`python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
|
|
194
|
-
return "\n".join(out)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def models() -> list[str]:
|
|
198
|
-
return [bench_collections.PREFIX + n for n in bench_collections.load()]
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
class Handler(BaseHTTPRequestHandler):
|
|
202
|
-
def _json(self, code: int, data: dict) -> None:
|
|
203
|
-
body = json.dumps(data).encode()
|
|
204
|
-
self.send_response(code)
|
|
205
|
-
self.send_header("Content-Type", "application/json")
|
|
206
|
-
self.send_header("Content-Length", str(len(body)))
|
|
207
|
-
self.end_headers()
|
|
208
|
-
self.wfile.write(body)
|
|
209
|
-
|
|
210
|
-
def _error(self, code: int, message: str) -> None:
|
|
211
|
-
self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}})
|
|
212
|
-
|
|
213
|
-
def do_GET(self): # noqa: N802
|
|
214
|
-
if self.path.split("?", 1)[0].rstrip("/") in ("/v1/models", "/models"):
|
|
215
|
-
self._json(200, {"object": "list", "data": [
|
|
216
|
-
{"id": m, "object": "model", "owned_by": "model-bench"} for m in models()]})
|
|
217
|
-
else:
|
|
218
|
-
self._error(404, "GET /v1/models is the only GET here")
|
|
219
|
-
|
|
220
|
-
def do_POST(self): # noqa: N802
|
|
221
|
-
try:
|
|
222
|
-
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
|
|
223
|
-
except ValueError:
|
|
224
|
-
return self._error(400, "body is not JSON, or Content-Length is not a number")
|
|
225
|
-
if not isinstance(body, dict):
|
|
226
|
-
return self._error(400, "body must be a JSON object")
|
|
227
|
-
path = self.path.split("?", 1)[0].rstrip("/")
|
|
228
|
-
if path == "/bench/verdict":
|
|
229
|
-
if not isinstance(body.get("useful"), bool):
|
|
230
|
-
return self._error(400, '"useful" must be true or false')
|
|
231
|
-
try:
|
|
232
|
-
e = offload.verdict(str(body.get("call", "")), body["useful"], str(body.get("note", "")))
|
|
233
|
-
except KeyError as exc:
|
|
234
|
-
return self._error(404, exc.args[0])
|
|
235
|
-
return self._json(200, {"ok": True, "recorded": e})
|
|
236
|
-
if path not in ("/v1/chat/completions", "/chat/completions"):
|
|
237
|
-
return self._error(404, "POST /v1/chat/completions or /bench/verdict")
|
|
238
|
-
model = body.get("model") or ""
|
|
239
|
-
name = bench_collections.resolve(model if isinstance(model, str) else "")
|
|
240
|
-
if name is None:
|
|
241
|
-
asked = f"{model!r} is not a collection here" if model else \
|
|
242
|
-
"no model given and no default collection is set"
|
|
243
|
-
return self._error(404, f"{asked}; collections: {', '.join(models()) or 'none yet'}"
|
|
244
|
-
" (created in the dashboard's Collections dialog)")
|
|
245
|
-
model = bench_collections.PREFIX + name
|
|
246
|
-
messages, nominated = body.get("messages"), body.get("with") or []
|
|
247
|
-
if not (isinstance(messages, list) and all(isinstance(m, dict) for m in messages)):
|
|
248
|
-
return self._error(400, '"messages" must be a list of message objects')
|
|
249
|
-
if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
|
|
250
|
-
return self._error(400, '"with" must be a list of model aliases')
|
|
251
|
-
try:
|
|
252
|
-
content = run_collection(name, messages, nominated)
|
|
253
|
-
except ValueError as exc:
|
|
254
|
-
return self._error(400, str(exc))
|
|
255
|
-
except LookupError as exc:
|
|
256
|
-
return self._error(503, str(exc))
|
|
257
|
-
reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
|
|
258
|
-
if not body.get("stream"):
|
|
259
|
-
return self._json(200, {
|
|
260
|
-
"id": reply_id, "object": "chat.completion", "created": created, "model": model,
|
|
261
|
-
"choices": [{"index": 0, "finish_reason": "stop",
|
|
262
|
-
"message": {"role": "assistant", "content": content}}]})
|
|
263
|
-
# A panel finishes all at once, so a streaming client gets it as one chunk.
|
|
264
|
-
self.send_response(200)
|
|
265
|
-
self.send_header("Content-Type", "text/event-stream")
|
|
266
|
-
self.end_headers()
|
|
267
|
-
for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
|
|
268
|
-
chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
|
|
269
|
-
"model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
|
|
270
|
-
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
|
271
|
-
self.wfile.write(b"data: [DONE]\n\n")
|
|
272
|
-
|
|
273
|
-
def log_message(self, fmt, *args): # quieter than the default
|
|
274
|
-
sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def main() -> None:
|
|
278
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
279
|
-
print(f"bench gateway on http://127.0.0.1:{PORT}/v1 — models: {', '.join(models())}")
|
|
280
|
-
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if __name__ == "__main__":
|
|
284
|
-
main()
|
|
1
|
+
"""The bench method as an OpenAI-compatible endpoint, at http://127.0.0.1:8123/v1.
|
|
2
|
+
|
|
3
|
+
python gateway.py
|
|
4
|
+
|
|
5
|
+
Ask for a COLLECTION by name — `bench/core`, `bench/reviewers` — and the prompt
|
|
6
|
+
goes in parallel to every member of it plus explorer seats `route.py` fills by
|
|
7
|
+
staleness, so the roster keeps getting evidence even when the same collection
|
|
8
|
+
is called every day:
|
|
9
|
+
|
|
10
|
+
explorers = min(max_explorers, max_calls - members) # defaults 2 and 5
|
|
11
|
+
|
|
12
|
+
Collections and those limits live in `config/collections.json` and
|
|
13
|
+
`config/gateway.json` (see `metrics/bench_collections.py`), edited from the
|
|
14
|
+
dashboard. A request with no model runs the default collection; a model name
|
|
15
|
+
that is not a collection is refused with the list, so a typo never runs one.
|
|
16
|
+
|
|
17
|
+
The reply is every answer, each labelled by seat and model with a call id,
|
|
18
|
+
including seats that failed or came back empty: who answered is evidence about
|
|
19
|
+
the roster, and a summary naming only the good answer hides it.
|
|
20
|
+
|
|
21
|
+
A collection's `shape` (review, design, …) decides which models may fill the
|
|
22
|
+
explorer seats. Left empty, a decision model labels the prompt when one that
|
|
23
|
+
takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
|
|
24
|
+
the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
|
|
25
|
+
model in an explorer chair, as `route.py --with` does.
|
|
26
|
+
|
|
27
|
+
THE LOOP
|
|
28
|
+
--------
|
|
29
|
+
Every seat is recorded in `metrics/offloads.jsonl` as an unjudged offload the
|
|
30
|
+
moment it answers, so the explorer seats rotate even when nobody judges. The
|
|
31
|
+
caller then checks the answers against the real code — agreement between seats
|
|
32
|
+
is not a verdict, they fail together — and records one per call id:
|
|
33
|
+
|
|
34
|
+
python metrics/offload.py verdict <call id> --useful --note "..."
|
|
35
|
+
POST /bench/verdict {"call": "<call id>", "useful": true, "note": "..."}
|
|
36
|
+
|
|
37
|
+
The verdict replaces the unjudged entry, and route.py's evidence — which
|
|
38
|
+
explorers are due, which models earn a place in a collection — reads it.
|
|
39
|
+
|
|
40
|
+
WHAT IT IS NOT
|
|
41
|
+
--------------
|
|
42
|
+
A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
|
|
43
|
+
exists only for the multi-model work. Seats are called with `llm -m`, exactly
|
|
44
|
+
as offloads always were, so endpoints, headers and per-model options all come
|
|
45
|
+
from the same registry. Full prompts and answers go to `runs/gateway/`,
|
|
46
|
+
gitignored. Bound to 127.0.0.1, like the dashboard.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import datetime as dt
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import pathlib
|
|
55
|
+
import secrets
|
|
56
|
+
import subprocess
|
|
57
|
+
import sys
|
|
58
|
+
import time
|
|
59
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
60
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
61
|
+
|
|
62
|
+
ROOT = pathlib.Path(__file__).resolve().parent
|
|
63
|
+
PORT = 8123
|
|
64
|
+
LOG_DIR = ROOT / "runs" / "gateway"
|
|
65
|
+
SEAT_TIMEOUT = 900
|
|
66
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
67
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
68
|
+
import bench_collections # noqa: E402
|
|
69
|
+
import decide # noqa: E402
|
|
70
|
+
import offload # noqa: E402
|
|
71
|
+
import route # noqa: E402
|
|
72
|
+
|
|
73
|
+
# What the shape picker is asked to choose between. Each option carries a
|
|
74
|
+
# description because bare names mislead it: Mercury Decide called "list every
|
|
75
|
+
# place serve.py writes a file" `code` at 0.93 when given names alone.
|
|
76
|
+
SHAPES = {
|
|
77
|
+
"general": "A general question or task that fits none of the other kinds.",
|
|
78
|
+
"design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
|
|
79
|
+
"prose": "Writing or editing documentation or other text.",
|
|
80
|
+
"review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
|
|
81
|
+
"code": "Writing new code or changing existing code.",
|
|
82
|
+
"enumeration": "Listing every place something happens in given code or files.",
|
|
83
|
+
"quick": "A short, bounded task: classify, extract or summarise a small input.",
|
|
84
|
+
}
|
|
85
|
+
CONFIDENT = 0.6
|
|
86
|
+
LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def flatten(messages: list[dict]) -> tuple[str, str]:
|
|
90
|
+
"""(system, prompt) for `llm`. A panel question is one prompt; a longer
|
|
91
|
+
conversation is kept as labelled turns rather than silently truncated."""
|
|
92
|
+
def text(m):
|
|
93
|
+
c = m.get("content") or ""
|
|
94
|
+
return c if isinstance(c, str) else "\n".join(
|
|
95
|
+
p.get("text", "") for p in c if isinstance(p, dict))
|
|
96
|
+
# Newer OpenAI SDKs send "developer" where older ones send "system".
|
|
97
|
+
lead = ("system", "developer")
|
|
98
|
+
system = "\n\n".join(text(m) for m in messages if m.get("role") in lead)
|
|
99
|
+
turns = [m for m in messages if m.get("role") not in lead]
|
|
100
|
+
if len(turns) == 1:
|
|
101
|
+
return system, text(turns[0])
|
|
102
|
+
return system, "\n\n".join(f"{m.get('role', 'user')}: {text(m)}" for m in turns)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pick_shape(prompt: str) -> tuple[str, str]:
|
|
106
|
+
"""(shape, how it was chosen). Falls back to general on any doubt."""
|
|
107
|
+
held = set(json.loads((ROOT / "config" / "model-roles.json").read_text(encoding="utf-8"))
|
|
108
|
+
.get("ad_hoc", []))
|
|
109
|
+
picker = next((a for a, d in decide.load().items()
|
|
110
|
+
if "choice" in d["question_types"] and a not in held), None)
|
|
111
|
+
if not picker:
|
|
112
|
+
return "general", "no decision model onboarded"
|
|
113
|
+
question = {"shape": {"type": "choice",
|
|
114
|
+
"instructions": "What kind of task does this prompt ask for?",
|
|
115
|
+
"criteria": SHAPES}}
|
|
116
|
+
try:
|
|
117
|
+
answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
|
|
118
|
+
except Exception as exc: # noqa: BLE001
|
|
119
|
+
return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
|
|
120
|
+
conf = answer.get("confidence", 0)
|
|
121
|
+
if answer.get("choice") not in SHAPES or conf < CONFIDENT:
|
|
122
|
+
return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
|
|
123
|
+
return answer["choice"], f"{picker}, {conf:.2f}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ask_seat(alias: str, system: str, prompt: str) -> dict:
|
|
127
|
+
cmd = ["llm", "-m", alias] + (["-s", system] if system else [])
|
|
128
|
+
started = time.monotonic()
|
|
129
|
+
try:
|
|
130
|
+
done = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
|
|
131
|
+
encoding="utf-8", errors="replace", timeout=SEAT_TIMEOUT,
|
|
132
|
+
env={**os.environ, "PYTHONIOENCODING": "utf-8"})
|
|
133
|
+
answer = done.stdout.strip()
|
|
134
|
+
# A non-zero exit is an error even when stderr is silent.
|
|
135
|
+
error = (done.stderr.strip() or f"llm exited {done.returncode}") if done.returncode else ""
|
|
136
|
+
except subprocess.TimeoutExpired:
|
|
137
|
+
answer, error = "", f"no answer in {SEAT_TIMEOUT}s"
|
|
138
|
+
except Exception as exc: # noqa: BLE001
|
|
139
|
+
# One seat failing to start must not lose the other seats' records.
|
|
140
|
+
answer, error = "", f"{type(exc).__name__}: {exc}"
|
|
141
|
+
status = "error" if error else ("empty" if not answer else "ok")
|
|
142
|
+
return {"status": status, "answer": answer, "error": error[-500:],
|
|
143
|
+
"seconds": round(time.monotonic() - started, 1)}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None) -> str:
|
|
147
|
+
system, prompt = flatten(messages)
|
|
148
|
+
if not prompt.strip():
|
|
149
|
+
raise ValueError("no user message to put to the collection")
|
|
150
|
+
collection = bench_collections.load()[name]
|
|
151
|
+
members = list(collection.get("members") or [])
|
|
152
|
+
n_explorers = bench_collections.explorers_for(len(members))
|
|
153
|
+
# The system text goes to every seat too, so it counts for shape and size.
|
|
154
|
+
whole = f"{system}\n\n{prompt}".strip()
|
|
155
|
+
if not n_explorers:
|
|
156
|
+
shape, how = collection.get("shape") or "general", "no explorer seats to fill"
|
|
157
|
+
elif collection.get("shape"):
|
|
158
|
+
shape, how = collection["shape"], "set on the collection"
|
|
159
|
+
else:
|
|
160
|
+
shape, how = pick_shape(whole)
|
|
161
|
+
seats = [(f"member{i}", "member", alias) for i, alias in enumerate(members, 1)]
|
|
162
|
+
if n_explorers:
|
|
163
|
+
# `with` in the request body is route.py's --with: an explorer seat.
|
|
164
|
+
p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
|
|
165
|
+
members=members, explorers=n_explorers)
|
|
166
|
+
seats += [(key, label, p[key]["model"]) for key, label in
|
|
167
|
+
(("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
|
|
168
|
+
if not seats:
|
|
169
|
+
raise LookupError(f"collection {name!r} has no members and no explorer could be seated")
|
|
170
|
+
panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
|
|
171
|
+
model = bench_collections.PREFIX + name
|
|
172
|
+
with ThreadPoolExecutor(max_workers=len(seats)) as pool:
|
|
173
|
+
results = list(pool.map(lambda s: ask_seat(s[2], system, prompt), seats))
|
|
174
|
+
|
|
175
|
+
task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
|
|
176
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
178
|
+
"model": model, "shape": shape, "shape_by": how, "system": system,
|
|
179
|
+
"prompt": prompt, "seats": []}
|
|
180
|
+
out = [f"Bench {model} {panel_id} · {len(members)} members + {len(seats) - len(members)}"
|
|
181
|
+
f" explorers · explorer shape {shape} ({how})\n"]
|
|
182
|
+
for (key, label, alias), r in zip(seats, results):
|
|
183
|
+
call = f"{panel_id}.{key}"
|
|
184
|
+
offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
|
|
185
|
+
+ (f": {r['error'][:120]}" if r["error"] else ""), call)
|
|
186
|
+
record["seats"].append({"call": call, "seat": label, "alias": alias, **r})
|
|
187
|
+
body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
|
|
188
|
+
"_empty reply — usually a token budget, not incapacity_")
|
|
189
|
+
out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
|
|
190
|
+
with (LOG_DIR / "calls.jsonl").open("a", encoding="utf-8") as fh:
|
|
191
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
192
|
+
out.append("---\nRecord a verdict per seat once checked against the code:\n"
|
|
193
|
+
"`python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
|
|
194
|
+
return "\n".join(out)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def models() -> list[str]:
|
|
198
|
+
return [bench_collections.PREFIX + n for n in bench_collections.load()]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Handler(BaseHTTPRequestHandler):
|
|
202
|
+
def _json(self, code: int, data: dict) -> None:
|
|
203
|
+
body = json.dumps(data).encode()
|
|
204
|
+
self.send_response(code)
|
|
205
|
+
self.send_header("Content-Type", "application/json")
|
|
206
|
+
self.send_header("Content-Length", str(len(body)))
|
|
207
|
+
self.end_headers()
|
|
208
|
+
self.wfile.write(body)
|
|
209
|
+
|
|
210
|
+
def _error(self, code: int, message: str) -> None:
|
|
211
|
+
self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}})
|
|
212
|
+
|
|
213
|
+
def do_GET(self): # noqa: N802
|
|
214
|
+
if self.path.split("?", 1)[0].rstrip("/") in ("/v1/models", "/models"):
|
|
215
|
+
self._json(200, {"object": "list", "data": [
|
|
216
|
+
{"id": m, "object": "model", "owned_by": "model-bench"} for m in models()]})
|
|
217
|
+
else:
|
|
218
|
+
self._error(404, "GET /v1/models is the only GET here")
|
|
219
|
+
|
|
220
|
+
def do_POST(self): # noqa: N802
|
|
221
|
+
try:
|
|
222
|
+
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
|
|
223
|
+
except ValueError:
|
|
224
|
+
return self._error(400, "body is not JSON, or Content-Length is not a number")
|
|
225
|
+
if not isinstance(body, dict):
|
|
226
|
+
return self._error(400, "body must be a JSON object")
|
|
227
|
+
path = self.path.split("?", 1)[0].rstrip("/")
|
|
228
|
+
if path == "/bench/verdict":
|
|
229
|
+
if not isinstance(body.get("useful"), bool):
|
|
230
|
+
return self._error(400, '"useful" must be true or false')
|
|
231
|
+
try:
|
|
232
|
+
e = offload.verdict(str(body.get("call", "")), body["useful"], str(body.get("note", "")))
|
|
233
|
+
except KeyError as exc:
|
|
234
|
+
return self._error(404, exc.args[0])
|
|
235
|
+
return self._json(200, {"ok": True, "recorded": e})
|
|
236
|
+
if path not in ("/v1/chat/completions", "/chat/completions"):
|
|
237
|
+
return self._error(404, "POST /v1/chat/completions or /bench/verdict")
|
|
238
|
+
model = body.get("model") or ""
|
|
239
|
+
name = bench_collections.resolve(model if isinstance(model, str) else "")
|
|
240
|
+
if name is None:
|
|
241
|
+
asked = f"{model!r} is not a collection here" if model else \
|
|
242
|
+
"no model given and no default collection is set"
|
|
243
|
+
return self._error(404, f"{asked}; collections: {', '.join(models()) or 'none yet'}"
|
|
244
|
+
" (created in the dashboard's Collections dialog)")
|
|
245
|
+
model = bench_collections.PREFIX + name
|
|
246
|
+
messages, nominated = body.get("messages"), body.get("with") or []
|
|
247
|
+
if not (isinstance(messages, list) and all(isinstance(m, dict) for m in messages)):
|
|
248
|
+
return self._error(400, '"messages" must be a list of message objects')
|
|
249
|
+
if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
|
|
250
|
+
return self._error(400, '"with" must be a list of model aliases')
|
|
251
|
+
try:
|
|
252
|
+
content = run_collection(name, messages, nominated)
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
return self._error(400, str(exc))
|
|
255
|
+
except LookupError as exc:
|
|
256
|
+
return self._error(503, str(exc))
|
|
257
|
+
reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
|
|
258
|
+
if not body.get("stream"):
|
|
259
|
+
return self._json(200, {
|
|
260
|
+
"id": reply_id, "object": "chat.completion", "created": created, "model": model,
|
|
261
|
+
"choices": [{"index": 0, "finish_reason": "stop",
|
|
262
|
+
"message": {"role": "assistant", "content": content}}]})
|
|
263
|
+
# A panel finishes all at once, so a streaming client gets it as one chunk.
|
|
264
|
+
self.send_response(200)
|
|
265
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
266
|
+
self.end_headers()
|
|
267
|
+
for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
|
|
268
|
+
chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
|
|
269
|
+
"model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
|
|
270
|
+
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
|
271
|
+
self.wfile.write(b"data: [DONE]\n\n")
|
|
272
|
+
|
|
273
|
+
def log_message(self, fmt, *args): # quieter than the default
|
|
274
|
+
sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main() -> None:
|
|
278
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
279
|
+
print(f"bench gateway on http://127.0.0.1:{PORT}/v1 — models: {', '.join(models())}")
|
|
280
|
+
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
main()
|