@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/logo-dark.svg +49 -0
- package/.github/logo.svg +49 -0
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +33 -11
- package/PRIVACY.md +20 -6
- package/README.md +46 -11
- package/SECURITY.md +20 -10
- package/USAGE.md +71 -9
- package/VERSION +1 -1
- package/gateway.py +93 -82
- package/metrics/bench_collections.py +27 -3
- package/metrics/catalog.py +52 -4
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/endpoints.py +4 -6
- package/metrics/guard.py +6 -4
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/route.py +9 -1
- package/metrics/scheduler.py +11 -1
- package/metrics/test_decide.py +275 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +44 -3
- package/scripts/test_connect.py +152 -3
- package/scripts/test_first_run.py +4 -2
- package/scripts/test_formats.py +39 -0
- package/scripts/test_serve_edit.py +308 -4
- package/serve.py +1672 -141
- package/suite/README.md +5 -2
- package/suite/run.py +226 -6
- package/suite/test_pick.py +15 -0
- package/suite/test_score.py +82 -1
- package/ui/bench.css +44 -0
- package/ui/bench.js +910 -203
package/gateway.py
CHANGED
|
@@ -11,9 +11,13 @@ is called every day:
|
|
|
11
11
|
|
|
12
12
|
Collections and those limits live in `config/collections.json` and
|
|
13
13
|
`config/gateway.json` (see `metrics/bench_collections.py`), edited from the
|
|
14
|
-
dashboard. There is no default
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
dashboard. There is no default. A request that names no collection gets one
|
|
15
|
+
a decision model chooses from the collections' descriptions and tags, when
|
|
16
|
+
it is sure (pick_collection; gateway.json `pick_collection`, on unless
|
|
17
|
+
false), and the reply says which and why (`bench.picked`, X-Bench-Picked).
|
|
18
|
+
Otherwise, and for a collection that does not exist, it is refused with
|
|
19
|
+
every collection's description and tags, so the caller can choose — `GET
|
|
20
|
+
/v1/models` carries the same.
|
|
17
21
|
|
|
18
22
|
The reply is every answer, each labelled by seat and model with a call id,
|
|
19
23
|
including seats that failed or came back empty: who answered is evidence about
|
|
@@ -30,8 +34,9 @@ one-line note: the panel the caller waited for is never lost.
|
|
|
30
34
|
|
|
31
35
|
A collection's `shape` (review, design, …) decides which models may fill the
|
|
32
36
|
explorer seats. Left empty, a decision model labels the prompt when one that
|
|
33
|
-
takes choice questions is onboarded (not ad hoc)
|
|
34
|
-
|
|
37
|
+
takes choice questions is onboarded (not ad hoc): the one metrics/decide.py
|
|
38
|
+
choose() names for the shape use; otherwise, or on any doubt, the shape is
|
|
39
|
+
`general`. A request body may add `"with": ["<alias>"]` to seat a
|
|
35
40
|
model in an explorer chair, as `route.py --with` does.
|
|
36
41
|
|
|
37
42
|
A retired model (`config/retired.json`) is never called: retiring takes it out
|
|
@@ -155,19 +160,10 @@ import model_roles # noqa: E402
|
|
|
155
160
|
import offload # noqa: E402
|
|
156
161
|
import route # noqa: E402
|
|
157
162
|
|
|
158
|
-
# What the shape picker
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"general": "A general question or task that fits none of the other kinds.",
|
|
163
|
-
"design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
|
|
164
|
-
"prose": "Writing or editing documentation or other text.",
|
|
165
|
-
"review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
|
|
166
|
-
"code": "Writing new code or changing existing code.",
|
|
167
|
-
"enumeration": "Listing every place something happens in given code or files.",
|
|
168
|
-
"quick": "A short, bounded task: classify, extract or summarise a small input.",
|
|
169
|
-
}
|
|
170
|
-
CONFIDENT = 0.6
|
|
163
|
+
# What the shape picker chooses between, and how sure it must be: in
|
|
164
|
+
# metrics/decide.py, whose benchmark asks the same question.
|
|
165
|
+
SHAPES = decide.SHAPES
|
|
166
|
+
CONFIDENT = decide.CONFIDENT
|
|
171
167
|
LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
|
|
172
168
|
# Bytes. A million-token prompt is about 4 MB, and a photo sent as a data URL
|
|
173
169
|
# is 4/3 of its file. 20 MB is the tightest whole-request limit among the big
|
|
@@ -177,9 +173,9 @@ TOO_BIG = (" Send images as http(s) URLs, or smaller ones: models scale a photo
|
|
|
177
173
|
" 1,500-2,000 pixels on its long side anyway.")
|
|
178
174
|
IMAGE_TOKENS = 2_500 # per image, in the size check (estimate_tokens says why)
|
|
179
175
|
IMAGE_URL = re.compile(r"(https?://[^\s/?#]+|data:image/[\w.+-]+;base64,)", re.I)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
176
|
+
# How an image or a URL is written down: in metrics/client.py, which writes a
|
|
177
|
+
# failed call's error to the ledger and so redacts it too.
|
|
178
|
+
from client import placeholder, redact # noqa: E402
|
|
183
179
|
HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
|
|
184
180
|
SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
|
|
185
181
|
QUERY_KEY = re.compile(r"([?&](?:key|api_key|access_token)=)[^&\s'\"]*", re.I)
|
|
@@ -243,32 +239,6 @@ def images(messages: list[dict]) -> list[str]:
|
|
|
243
239
|
return urls
|
|
244
240
|
|
|
245
241
|
|
|
246
|
-
def placeholder(url: str) -> str:
|
|
247
|
-
"""How an image is written down (the call log, offloads, the dashboard,
|
|
248
|
-
error text, the merger's copy of the request), never its bytes: a data
|
|
249
|
-
URL by its size, an http URL without its query (a signed URL's signature
|
|
250
|
-
is a credential) or a user:password."""
|
|
251
|
-
if url[:5].lower() == "data:":
|
|
252
|
-
return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
|
|
253
|
-
u = urllib.parse.urlsplit(url)
|
|
254
|
-
return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def redact(text: str) -> str:
|
|
258
|
-
"""`text` with each inline image as its placeholder, every http(s) URL
|
|
259
|
-
without its query or user:password, and any other long base64 run as its
|
|
260
|
-
size: an upstream may echo the request in an error, in whatever form, and
|
|
261
|
-
errors are logged, shown and learned from."""
|
|
262
|
-
def bare(m):
|
|
263
|
-
try:
|
|
264
|
-
u = urllib.parse.urlsplit(m.group())
|
|
265
|
-
except ValueError: # an unparseable netloc, such as "[::1"
|
|
266
|
-
return "[url]"
|
|
267
|
-
return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
|
|
268
|
-
text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
|
|
269
|
-
return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
|
|
270
|
-
|
|
271
|
-
|
|
272
242
|
def cannot_see(alias: str) -> str:
|
|
273
243
|
"""Why an image cannot be put to `alias`, or "" when it can. The catalog
|
|
274
244
|
decides (metrics/catalog.py: the model's input types, your Vision / OCR
|
|
@@ -289,29 +259,45 @@ def cannot_see(alias: str) -> str:
|
|
|
289
259
|
|
|
290
260
|
|
|
291
261
|
def pick_shape(prompt: str) -> tuple[str, str]:
|
|
292
|
-
"""(shape, how it was chosen).
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if not picker:
|
|
301
|
-
return "general", "no decision model onboarded"
|
|
302
|
-
question = {"shape": {"type": "choice",
|
|
303
|
-
"instructions": "What kind of task does this prompt ask for?",
|
|
304
|
-
"criteria": SHAPES}}
|
|
305
|
-
try:
|
|
306
|
-
answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
|
|
307
|
-
except Exception as exc: # noqa: BLE001
|
|
308
|
-
return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
|
|
309
|
-
conf = answer.get("confidence", 0)
|
|
262
|
+
"""(shape, how it was chosen). The decision model decide.choose names for
|
|
263
|
+
the shape use is asked (ranked on the collection set, never a `:free` one
|
|
264
|
+
while another answers; the first onboarded before any is ranked), and the
|
|
265
|
+
call is logged. Falls back to general on any doubt."""
|
|
266
|
+
answer, picker, why = decide.pick("shape", prompt, SHAPES, "pick_shape")
|
|
267
|
+
if why:
|
|
268
|
+
return "general", why
|
|
269
|
+
conf = float(answer.get("confidence") or 0)
|
|
310
270
|
if answer.get("choice") not in SHAPES or conf < CONFIDENT:
|
|
311
271
|
return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
|
|
312
272
|
return answer["choice"], f"{picker}, {conf:.2f}"
|
|
313
273
|
|
|
314
274
|
|
|
275
|
+
def pick_collection(messages: list[dict], who: str) -> tuple[str | None, dict]:
|
|
276
|
+
"""For a request that names no collection: (the collection a decision
|
|
277
|
+
model chose for it, what the reply says about that), or (None, {}) when
|
|
278
|
+
gateway.json's `pick_collection` is off, nothing is on offer, or the model
|
|
279
|
+
is not sure (its confidence under `pick_confidence`, else CONFIDENT); the
|
|
280
|
+
request is then refused as before. The model best on the benchmark's
|
|
281
|
+
collection set chooses among the collections this key may call that are
|
|
282
|
+
not ad hoc, from their descriptions and tags, as choose_message lists them."""
|
|
283
|
+
s = bench_collections.pick_settings()
|
|
284
|
+
cols = {n: c for n, c in bench_collections.load().items() if not c.get("ad_hoc") and guard.may_call(who, n)}
|
|
285
|
+
if not s["pick_collection"] or not cols:
|
|
286
|
+
return None, {}
|
|
287
|
+
system, prompt = flatten(messages)
|
|
288
|
+
whole = decide.request_text({"system": system, "prompt": prompt}) # as the benchmark asks it
|
|
289
|
+
if not whole:
|
|
290
|
+
return None, {}
|
|
291
|
+
answer, by, why = decide.pick("collection", whole, decide.collection_options(cols), "pick_collection")
|
|
292
|
+
conf = float(answer.get("confidence") or 0)
|
|
293
|
+
if why or answer.get("choice") not in cols or conf < (s["pick_confidence"] or CONFIDENT):
|
|
294
|
+
return None, {}
|
|
295
|
+
name = answer["choice"]
|
|
296
|
+
return name, {"collection": bench_collections.PREFIX + name, "by": by, "confidence": round(conf, 2),
|
|
297
|
+
"why": f"no collection was named, so {by} chose it from the collections' descriptions and"
|
|
298
|
+
f" tags, at {conf:.2f}"}
|
|
299
|
+
|
|
300
|
+
|
|
315
301
|
def retired() -> dict[str, dict]:
|
|
316
302
|
"""config/retired.json: alias -> its record (when, why). Retiring a model
|
|
317
303
|
takes it out of every collection (serve.remove_model); this is for a
|
|
@@ -390,9 +376,10 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
|
|
|
390
376
|
else:
|
|
391
377
|
answer, error = _ask_llm(cmd, prompt)
|
|
392
378
|
# A quota or credit notice delivered as the reply's text, with a success
|
|
393
|
-
# exit, is not an answer (suite/run.py PROVIDER_NOTICES
|
|
379
|
+
# exit, is not an answer (suite/run.py PROVIDER_NOTICES; a short reply
|
|
380
|
+
# that only looks like one is put to a decision model, once per shape).
|
|
394
381
|
from run import provider_notice
|
|
395
|
-
if not error and (notice := provider_notice(answer)):
|
|
382
|
+
if not error and (notice := provider_notice(answer, ask=True)):
|
|
396
383
|
error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
|
|
397
384
|
if messages is not None:
|
|
398
385
|
# Before the error is cut, logged or learned from. ponytail: the answer
|
|
@@ -407,7 +394,8 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
|
|
|
407
394
|
except Exception: # noqa: BLE001
|
|
408
395
|
pass
|
|
409
396
|
status = "error" if error else ("empty" if not answer else "ok")
|
|
410
|
-
|
|
397
|
+
# Its start and end: the start holds the HTTP status a long body's tail lost.
|
|
398
|
+
return {"status": status, "answer": answer, "error": client.clip(error),
|
|
411
399
|
"seconds": round(time.monotonic() - started, 1)}
|
|
412
400
|
|
|
413
401
|
|
|
@@ -555,10 +543,13 @@ def section(seat: dict) -> str:
|
|
|
555
543
|
|
|
556
544
|
|
|
557
545
|
def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
|
|
558
|
-
reply: str = "", who: str = "local", on_part=None) -> tuple[str, dict]:
|
|
546
|
+
reply: str = "", who: str = "local", on_part=None, picked: dict | None = None) -> tuple[str, dict]:
|
|
559
547
|
"""The panel's reply as markdown, and the same seats as data for the
|
|
560
548
|
response's `bench` field. `reply`: the request's mode, "" for the
|
|
561
549
|
collection's own. `who`: the gateway key that asked, for the call log.
|
|
550
|
+
`picked`: the request named no collection and a decision model chose
|
|
551
|
+
this one (pick_collection); the reply says so, in `bench.picked` and its
|
|
552
|
+
heading, and the call log keeps it (so the benchmark never learns from it).
|
|
562
553
|
|
|
563
554
|
`on_part`, for a streaming caller: called with each piece of an
|
|
564
555
|
every-answer reply as it is ready, the heading once the panel is seated,
|
|
@@ -669,6 +660,8 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
669
660
|
task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
|
|
670
661
|
out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
|
|
671
662
|
f" explorers · explorer shape {shape} ({how})\n"]
|
|
663
|
+
if picked:
|
|
664
|
+
out.append(f"Collection {model} chosen for this request: {picked['why']}.\n")
|
|
672
665
|
if skipped:
|
|
673
666
|
out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
|
|
674
667
|
emit = None if merged else on_part
|
|
@@ -691,7 +684,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
691
684
|
record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
692
685
|
"model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
|
|
693
686
|
"prompt": prompt, "seats": [done[i] for i in range(len(seats))],
|
|
694
|
-
**({"skipped": skipped} if skipped else {})}
|
|
687
|
+
**({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
|
|
695
688
|
out += [section(s) for s in record["seats"]]
|
|
696
689
|
footer = ("---\nRecord a verdict per seat once checked against the code:\n"
|
|
697
690
|
"`POST /bench/verdict` on this same server (see `bench.verdict`), or"
|
|
@@ -707,7 +700,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
707
700
|
# A path, not a URL: the same server answered, whichever port or
|
|
708
701
|
# proxy the caller came through (the dashboard serves these routes too).
|
|
709
702
|
"verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
|
|
710
|
-
**({"skipped": skipped} if skipped else {})}
|
|
703
|
+
**({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
|
|
711
704
|
if merged:
|
|
712
705
|
try:
|
|
713
706
|
merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
|
|
@@ -762,6 +755,11 @@ def bench_headers(bench: dict) -> dict:
|
|
|
762
755
|
"X-Bench-Seats": json.dumps([{k: s.get(k) for k in ("call", "seat", "alias", "status")} for s in seats])}
|
|
763
756
|
|
|
764
757
|
|
|
758
|
+
def picked_headers(picked: dict | None) -> dict:
|
|
759
|
+
"""X-Bench-Picked, when a decision model chose the collection: which, by whom, how sure."""
|
|
760
|
+
return {"X-Bench-Picked": f"{picked['collection']}; {picked['by']}, {picked['confidence']:.2f}"} if picked else {}
|
|
761
|
+
|
|
762
|
+
|
|
765
763
|
def refusal(exc: Exception) -> tuple[int, str]:
|
|
766
764
|
"""The status and message for a panel that could not run."""
|
|
767
765
|
if isinstance(exc, TooLarge):
|
|
@@ -1003,6 +1001,7 @@ def route_turn(name: str, plan: dict, ask: dict, who: str, emit) -> None:
|
|
|
1003
1001
|
return emit("done", {"collection": model, "reply": "route", "conversation": conv["conv"], "call": call,
|
|
1004
1002
|
"alias": alias, "turn": turn_no, **({"failed_over": tried} if tried else {}),
|
|
1005
1003
|
**({"skipped": plan["skipped"]} if plan["skipped"] else {}),
|
|
1004
|
+
**({"picked": plan["picked"]} if plan.get("picked") else {}),
|
|
1006
1005
|
"verdict": 'optional, once per conversation: POST /bench/verdict {"call", "useful", "note"}'})
|
|
1007
1006
|
_log_route(conv, conv["turns"], "", bench_collections.PREFIX + name, who, "", "error", None, tried,
|
|
1008
1007
|
"every member failed")
|
|
@@ -1064,7 +1063,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1064
1063
|
and (origin := guard.cors_origin(headers, self.server.server_port)):
|
|
1065
1064
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
1066
1065
|
self.send_header("Access-Control-Expose-Headers",
|
|
1067
|
-
"X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model")
|
|
1066
|
+
"X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model, X-Bench-Picked")
|
|
1068
1067
|
self.send_header("Vary", "Origin")
|
|
1069
1068
|
super().end_headers()
|
|
1070
1069
|
|
|
@@ -1275,6 +1274,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1275
1274
|
the key's scope and cap, then the panel, answered in that format."""
|
|
1276
1275
|
model, suffix = bench_collections.split_reply(ask["model"])
|
|
1277
1276
|
name = bench_collections.resolve(model)
|
|
1277
|
+
picked = {}
|
|
1278
|
+
if name is None and not model:
|
|
1279
|
+
# NO COLLECTION NAMED: a decision model may choose one (pick_collection); when it does
|
|
1280
|
+
# not, the refusal is the one there always was, and costs the key nothing.
|
|
1281
|
+
try:
|
|
1282
|
+
name, picked = pick_collection(ask["messages"], self.who)
|
|
1283
|
+
except Exception: # noqa: BLE001 -- refused as before, never a 500
|
|
1284
|
+
name, picked = None, {}
|
|
1278
1285
|
if name is None:
|
|
1279
1286
|
return self._error(400 if not model else 404, bench_collections.choose_message(model), fmt=fmt)
|
|
1280
1287
|
model = bench_collections.PREFIX + name
|
|
@@ -1283,30 +1290,31 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1283
1290
|
return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}",
|
|
1284
1291
|
fmt=fmt)
|
|
1285
1292
|
try:
|
|
1286
|
-
guard.spend(self.who) # counted once it names a collection it may call
|
|
1293
|
+
guard.spend(self.who) # counted once it names (or is given) a collection it may call
|
|
1287
1294
|
except guard.Refused as r:
|
|
1288
1295
|
return self._error(r.status, str(r), r.headers, fmt)
|
|
1289
1296
|
reply = ask["reply"] or suffix # the body is the most specific, then the suffix, then the collection
|
|
1290
1297
|
# Chat completions have always answered with the collection's name;
|
|
1291
1298
|
# the other formats echo the name as asked, which their clients match.
|
|
1292
|
-
shown = model if fmt == "chat" else ask["model"]
|
|
1299
|
+
shown = model if fmt == "chat" or picked else ask["model"]
|
|
1293
1300
|
if (reply or bench_collections.load()[name].get("reply")) == "route":
|
|
1294
|
-
return self._route(fmt, name, ask, shown, sse)
|
|
1301
|
+
return self._route(fmt, name, ask, shown, sse, picked)
|
|
1295
1302
|
if ask["agentic"]:
|
|
1296
1303
|
return self._error(400, formats.TOOLS, fmt=fmt)
|
|
1297
1304
|
if ask["stream"]:
|
|
1298
1305
|
out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
|
|
1299
|
-
return self._stream(fmt, name, ask, reply, out)
|
|
1306
|
+
return self._stream(fmt, name, ask, reply, out, picked)
|
|
1300
1307
|
started = time.monotonic()
|
|
1301
1308
|
try:
|
|
1302
|
-
content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who
|
|
1309
|
+
content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who,
|
|
1310
|
+
**({"picked": picked} if picked else {}))
|
|
1303
1311
|
except (ValueError, LookupError) as exc: # TooLarge is a ValueError
|
|
1304
1312
|
return self._error(*refusal(exc), fmt=fmt)
|
|
1305
1313
|
usage = (prompt_tokens(ask["messages"]), estimate_tokens(content))
|
|
1306
1314
|
self._json(200, formats.INBOUND[fmt].reply(content, shown, bench, usage, time.monotonic() - started),
|
|
1307
|
-
bench_headers(bench) if fmt == "gemini" else None)
|
|
1315
|
+
{**(bench_headers(bench) if fmt == "gemini" else {}), **picked_headers(picked)} or None)
|
|
1308
1316
|
|
|
1309
|
-
def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream) -> None:
|
|
1317
|
+
def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream, picked: dict | None = None) -> None:
|
|
1310
1318
|
"""The panel as it comes, in the format's stream shape: every answer
|
|
1311
1319
|
seat by seat as each finishes, or a merged reply when the merge is
|
|
1312
1320
|
done. The panel runs in its own thread and this one writes, so a
|
|
@@ -1320,7 +1328,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1320
1328
|
def work():
|
|
1321
1329
|
try:
|
|
1322
1330
|
events.put(("done", run_collection(name, ask["messages"], ask["with"], reply, self.who,
|
|
1323
|
-
on_part=lambda text: events.put(("part", text)))))
|
|
1331
|
+
on_part=lambda text: events.put(("part", text)), picked=picked)))
|
|
1324
1332
|
except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
|
|
1325
1333
|
events.put(("error", exc))
|
|
1326
1334
|
threading.Thread(target=work, daemon=True).start()
|
|
@@ -1337,6 +1345,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1337
1345
|
self.send_response(200)
|
|
1338
1346
|
self.send_header("Content-Type", out.content_type)
|
|
1339
1347
|
self.send_header("Cache-Control", "no-cache")
|
|
1348
|
+
for k, v in picked_headers(picked).items():
|
|
1349
|
+
self.send_header(k, v)
|
|
1340
1350
|
self.end_headers()
|
|
1341
1351
|
self.wfile.write(out.start())
|
|
1342
1352
|
started = True
|
|
@@ -1355,7 +1365,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1355
1365
|
except OSError:
|
|
1356
1366
|
return # the caller left; the panel still finishes, every seat recorded
|
|
1357
1367
|
|
|
1358
|
-
def _route(self, fmt: str, name: str, ask: dict, shown: str, sse: bool = True) -> None:
|
|
1368
|
+
def _route(self, fmt: str, name: str, ask: dict, shown: str, sse: bool = True, picked: dict | None = None) -> None:
|
|
1359
1369
|
"""Route mode: one member's answer, in the request's format. The
|
|
1360
1370
|
turn runs in its own thread (route_turn) and this one writes: a
|
|
1361
1371
|
whole reply once it is done, or a stream whose 200 waits for the
|
|
@@ -1365,6 +1375,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1365
1375
|
plan = route_plan(name, ask, self.who)
|
|
1366
1376
|
except (ValueError, LookupError) as exc: # TooLarge is a ValueError
|
|
1367
1377
|
return self._error(*refusal(exc), fmt=fmt)
|
|
1378
|
+
plan["picked"] = picked or {}
|
|
1368
1379
|
events: queue.Queue = queue.Queue()
|
|
1369
1380
|
|
|
1370
1381
|
def work():
|
|
@@ -1374,7 +1385,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1374
1385
|
events.put(("error", (500, f"route mode failed ({type(exc).__name__}: {exc})")))
|
|
1375
1386
|
threading.Thread(target=work, daemon=True).start()
|
|
1376
1387
|
tokens, began = route_tokens(ask), time.monotonic()
|
|
1377
|
-
headers = {"X-Bench-Conversation": plan["conv"]["conv"]}
|
|
1388
|
+
headers = {"X-Bench-Conversation": plan["conv"]["conv"], **picked_headers(picked)}
|
|
1378
1389
|
if not ask["stream"]:
|
|
1379
1390
|
turn = formats.Turn()
|
|
1380
1391
|
while True:
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
config/collections.json {"core": {"members": [...], "shape": "",
|
|
4
4
|
"description": "...", "tags": ["general"],
|
|
5
5
|
"reply": "all", "merger": ""}}
|
|
6
|
-
config/gateway.json {"max_calls": 5, "max_explorers": 2
|
|
6
|
+
config/gateway.json {"max_calls": 5, "max_explorers": 2,
|
|
7
|
+
"pick_collection": true, "pick_confidence": 0.6}
|
|
7
8
|
|
|
8
9
|
A request for `bench/core` runs every member, plus explorer seats that
|
|
9
10
|
`route.py` fills by staleness so the roster keeps getting evidence even when
|
|
@@ -76,6 +77,17 @@ def settings() -> dict:
|
|
|
76
77
|
return {**DEFAULTS, **{k: v for k, v in _read(SETTINGS_FILE).items() if k in DEFAULTS}}
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def pick_settings() -> dict:
|
|
81
|
+
"""gateway.json's `pick_collection` (on unless false: a request that names
|
|
82
|
+
no collection gets one a decision model chooses, gateway.pick_collection)
|
|
83
|
+
and `pick_confidence` (above 0, at most 1; None means decide.CONFIDENT).
|
|
84
|
+
Kept beside the limits, which save_gateway_settings leaves alone."""
|
|
85
|
+
raw = _read(SETTINGS_FILE)
|
|
86
|
+
on, at = raw.get("pick_collection"), raw.get("pick_confidence")
|
|
87
|
+
return {"pick_collection": on is not False,
|
|
88
|
+
"pick_confidence": at if isinstance(at, (int, float)) and not isinstance(at, bool) and 0 < at <= 1 else None}
|
|
89
|
+
|
|
90
|
+
|
|
79
91
|
def limit_ok(key: str, value) -> bool:
|
|
80
92
|
return isinstance(value, int) and not isinstance(value, bool) and BOUNDS[key][0] <= value <= BOUNDS[key][1]
|
|
81
93
|
|
|
@@ -117,8 +129,10 @@ def described(name: str, c: dict, s: dict | None = None) -> dict:
|
|
|
117
129
|
|
|
118
130
|
def resolve(model: str | None) -> str | None:
|
|
119
131
|
"""The collection a request's `model` names: `bench/core` or `core`; None
|
|
120
|
-
when it names nothing here. There is no default: a
|
|
121
|
-
|
|
132
|
+
when it names nothing here. There is no default: a request with no model
|
|
133
|
+
gets one a decision model chooses when it is sure (gateway.pick_collection,
|
|
134
|
+
pick_settings), and is otherwise told what each collection is for and
|
|
135
|
+
asked to choose, as is one naming a collection that does not exist."""
|
|
122
136
|
name = (model or "").strip().removeprefix(PREFIX) # "bench/" alone names nothing
|
|
123
137
|
return name if name and name in load() else None
|
|
124
138
|
|
|
@@ -165,6 +179,16 @@ if __name__ == "__main__":
|
|
|
165
179
|
assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
|
|
166
180
|
assert split_reply("bench/core:route") == ("bench/core", "route")
|
|
167
181
|
assert split_reply("bench/core") == ("bench/core", "") and split_reply("bench/core:x") == ("bench/core:x", "")
|
|
182
|
+
import tempfile
|
|
183
|
+
real = SETTINGS_FILE
|
|
184
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
185
|
+
SETTINGS_FILE = pathlib.Path(tmp) / "gateway.json"
|
|
186
|
+
assert pick_settings() == {"pick_collection": True, "pick_confidence": None} # on by default
|
|
187
|
+
SETTINGS_FILE.write_text('{"pick_collection": false, "pick_confidence": 0.8}', encoding="utf-8")
|
|
188
|
+
assert pick_settings() == {"pick_collection": False, "pick_confidence": 0.8}
|
|
189
|
+
SETTINGS_FILE.write_text('{"pick_confidence": 2}', encoding="utf-8") # out of range: unset
|
|
190
|
+
assert pick_settings() == {"pick_collection": True, "pick_confidence": None}
|
|
191
|
+
SETTINGS_FILE = real
|
|
168
192
|
assert not reply_errors("merged", "", []) and not reply_errors("all", "m", ["m"])
|
|
169
193
|
assert len(reply_errors("", "m", [])) == 2
|
|
170
194
|
print("ok")
|
package/metrics/catalog.py
CHANGED
|
@@ -31,6 +31,12 @@ WHERE THE FACTS COME FROM
|
|
|
31
31
|
the same weights, but a provider can cap context lower.
|
|
32
32
|
- Open WebUI's /api/models, where a connection passes metadata through
|
|
33
33
|
(Inception, Reflection do; most do not).
|
|
34
|
+
- A local model server's own API, for what its /v1/models leaves out: LM
|
|
35
|
+
Studio's /api/v1/models (vision, tool use, reasoning; the context window a
|
|
36
|
+
loaded model runs with, else the most it can take) and Ollama's /api/show
|
|
37
|
+
(its capabilities, and `<arch>.context_length` from model_info, the trained
|
|
38
|
+
window: Ollama may run it with less). Like a connection's own listing, these
|
|
39
|
+
beat a same-id match from anywhere else.
|
|
34
40
|
- Everything else, including rate limits, by hand: freellmapi.co/models is a
|
|
35
41
|
good place to look them up. The bench never fetches it; its data licence is
|
|
36
42
|
not stated.
|
|
@@ -64,8 +70,9 @@ LIMIT_LABELS = {"rpm": "requests per minute", "rpd": "requests per day", "tpm":
|
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
def _read(path: pathlib.Path) -> dict:
|
|
73
|
+
import locked
|
|
67
74
|
try:
|
|
68
|
-
data = json.loads(
|
|
75
|
+
data = json.loads(locked.read_text(path)) # inside a render, from its snapshot
|
|
69
76
|
except (OSError, ValueError):
|
|
70
77
|
return {}
|
|
71
78
|
return data if isinstance(data, dict) else {}
|
|
@@ -151,6 +158,39 @@ def _fetch(url: str, headers: dict | None = None) -> dict:
|
|
|
151
158
|
return json.load(r)
|
|
152
159
|
|
|
153
160
|
|
|
161
|
+
def _native(name: str, served) -> tuple[str, dict]:
|
|
162
|
+
"""What a local model server says of its own models that its /v1/models
|
|
163
|
+
leaves out: LM Studio's /api/v1/models, else Ollama's /api/show, one call
|
|
164
|
+
for each of `served`. (source, {served id: facts}); ("", {}) from any other."""
|
|
165
|
+
import connect
|
|
166
|
+
import endpoints
|
|
167
|
+
root, secret = endpoints.base(name).removesuffix("/v1"), endpoints.key(name)
|
|
168
|
+
auth = endpoints.auth_headers("chat", secret) if secret else {}
|
|
169
|
+
status, data = connect.look(root + "/api/v1/models", 5, headers=auth)
|
|
170
|
+
out: dict[str, dict] = {}
|
|
171
|
+
if status == 200 and isinstance(data, dict) and isinstance(data.get("models"), list):
|
|
172
|
+
for m in data["models"]:
|
|
173
|
+
caps, runs = m.get("capabilities") or {}, m.get("loaded_instances") or []
|
|
174
|
+
# Loaded: the window it runs with now; else the most it can take.
|
|
175
|
+
ctx = min(filter(None, ((i.get("config") or {}).get("context_length") for i in runs)),
|
|
176
|
+
default=m.get("max_context_length"))
|
|
177
|
+
facts = {"context_window": ctx, "vision": caps.get("vision"),
|
|
178
|
+
"tools": caps.get("trained_for_tool_use"), "reasoning": "reasoning" in caps}
|
|
179
|
+
# A request names the key, a variant (key@q4_k_m) or a loaded instance's id.
|
|
180
|
+
for i in (m.get("key"), *(m.get("variants") or ()), *(r.get("id") for r in runs)):
|
|
181
|
+
out[i] = facts
|
|
182
|
+
return "lmstudio /api/v1/models", out
|
|
183
|
+
for s in served:
|
|
184
|
+
status, d = connect.look(root + "/api/show", 5, {"model": s}, auth)
|
|
185
|
+
if status == 200 and isinstance(d, dict) and isinstance(d.get("capabilities"), list):
|
|
186
|
+
caps, mi = d["capabilities"], d.get("model_info") or {}
|
|
187
|
+
# ponytail: the trained window; Ollama runs a model with its own num_ctx
|
|
188
|
+
# (OLLAMA_CONTEXT_LENGTH, a Modelfile's) and cuts a longer prompt silently.
|
|
189
|
+
out[s] = {"context_window": mi.get(f"{mi.get('general.architecture')}.context_length"),
|
|
190
|
+
"vision": "vision" in caps, "tools": "tools" in caps, "reasoning": "thinking" in caps}
|
|
191
|
+
return ("ollama /api/show" if out else ""), out
|
|
192
|
+
|
|
193
|
+
|
|
154
194
|
def _watch_price(alias: str, served: str, conn: str, old: dict | None, new: dict | None) -> None:
|
|
155
195
|
"""A free model that now has a price: raise it, before calls start failing.
|
|
156
196
|
models.dev can lag, so this is an early warning, not proof; the bench's own
|
|
@@ -188,11 +228,17 @@ def refresh() -> dict:
|
|
|
188
228
|
# is registered on: the direct openrouter endpoint lists qwen/qwen3.8-27b
|
|
189
229
|
# too, and pooling the listings filed OpenRouter's 1M window as groq's.
|
|
190
230
|
listings: dict[str, dict] = {}
|
|
231
|
+
native: dict[str, tuple[str, dict]] = {} # a local server's own API, what /v1/models leaves out
|
|
191
232
|
for name in endpoints.load():
|
|
192
233
|
try:
|
|
193
234
|
listings[name] = endpoints.models(name)
|
|
194
235
|
except Exception: # noqa: BLE001
|
|
195
236
|
listings[name] = {}
|
|
237
|
+
try:
|
|
238
|
+
if endpoints.kind(name) == "local":
|
|
239
|
+
native[name] = _native(name, listings[name])
|
|
240
|
+
except Exception: # noqa: BLE001 -- an answer of another shape: nothing from it
|
|
241
|
+
pass
|
|
196
242
|
home = {r["alias"]: r["endpoint"] for r in endpoints.registry()}
|
|
197
243
|
models = {}
|
|
198
244
|
for alias, served in registered.items():
|
|
@@ -269,9 +315,11 @@ def refresh() -> dict:
|
|
|
269
315
|
structured_output=bool(feats & {"json_mode", "structured_outputs"}))
|
|
270
316
|
if entry.get("input_modalities") is not None:
|
|
271
317
|
own["vision"] = "image" in entry["input_modalities"]
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
318
|
+
said, facts = native.get(ep, ("", {}))
|
|
319
|
+
for mine, by in ((own, f"{ep or 'endpoint'} listing"), (facts.get(served, {}), said)):
|
|
320
|
+
for k, v in mine.items():
|
|
321
|
+
if v is not None:
|
|
322
|
+
rec[k], src[k] = v, by
|
|
275
323
|
rec["sources"] = src
|
|
276
324
|
models[alias] = rec
|
|
277
325
|
_watch_price(alias, served, conn, before.get(alias, {}).get("cost"), rec.get("cost"))
|
package/metrics/client.py
CHANGED
|
@@ -57,7 +57,7 @@ down sets.
|
|
|
57
57
|
WHAT EVERY CALL LEAVES BEHIND (2b)
|
|
58
58
|
----------------------------------
|
|
59
59
|
- a ledger line (metrics/ledger.py): tokens in and out, seconds, status,
|
|
60
|
-
attempts, caller;
|
|
60
|
+
attempts, caller, and a failed call's error (`clip`: its start and end);
|
|
61
61
|
- quota records from the reply's rate-limit headers and any 429 body
|
|
62
62
|
(metrics/quota.py);
|
|
63
63
|
- a silent-swap note when the reply names another model than the one
|
|
@@ -119,6 +119,7 @@ import json
|
|
|
119
119
|
import os
|
|
120
120
|
import pathlib
|
|
121
121
|
import random
|
|
122
|
+
import re
|
|
122
123
|
import sys
|
|
123
124
|
import threading
|
|
124
125
|
import time
|
|
@@ -852,6 +853,11 @@ def _record(alias: str, res: dict, ctx: dict, t0: float, purpose: str) -> None:
|
|
|
852
853
|
row["swap"] = True
|
|
853
854
|
if res.get("budget_retry"):
|
|
854
855
|
row["budget_retry"] = res["budget_retry"]
|
|
856
|
+
if status == "error":
|
|
857
|
+
# What failed, so the uptime report can tell our quota from the
|
|
858
|
+
# provider being down for calls no other log keeps (the suite's).
|
|
859
|
+
# Redacted first: an upstream may echo the request, images and all.
|
|
860
|
+
row["error"] = clip(redact(res["error"] or ""))
|
|
855
861
|
ledger.append(row)
|
|
856
862
|
except Exception as e: # noqa: BLE001
|
|
857
863
|
# The slot stays taken until it expires (the call's deadline and a
|
|
@@ -906,6 +912,45 @@ def output(res: dict, timeout: float) -> str:
|
|
|
906
912
|
return res["error"] or (res["text"] + "\n" if res["text"] else "")
|
|
907
913
|
|
|
908
914
|
|
|
915
|
+
DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
|
|
916
|
+
URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
|
|
917
|
+
BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def placeholder(url: str) -> str:
|
|
921
|
+
"""How an image is written down (the call log, offloads, the dashboard,
|
|
922
|
+
error text, the merger's copy of the request), never its bytes: a data
|
|
923
|
+
URL by its size, an http URL without its query (a signed URL's signature
|
|
924
|
+
is a credential) or a user:password."""
|
|
925
|
+
if url[:5].lower() == "data:":
|
|
926
|
+
return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
|
|
927
|
+
u = urllib.parse.urlsplit(url)
|
|
928
|
+
return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def redact(text: str) -> str:
|
|
932
|
+
"""`text` with each inline image as its placeholder, every http(s) URL
|
|
933
|
+
without its query or user:password, and any other long base64 run as its
|
|
934
|
+
size: an upstream may echo the request in an error, in whatever form, and
|
|
935
|
+
errors are logged, shown and learned from."""
|
|
936
|
+
def bare(m):
|
|
937
|
+
try:
|
|
938
|
+
u = urllib.parse.urlsplit(m.group())
|
|
939
|
+
except ValueError: # an unparseable netloc, such as "[::1"
|
|
940
|
+
return "[url]"
|
|
941
|
+
return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
|
|
942
|
+
text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
|
|
943
|
+
return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def clip(text: str, head: int = 200, tail: int = 300) -> str:
|
|
947
|
+
"""An error kept short for a log, its start AND its end: the start says the
|
|
948
|
+
HTTP status (`Error: Error code: 429 - ...`), the end what a long body
|
|
949
|
+
(headers, a traceback, an HTML page) finally said. The uptime report
|
|
950
|
+
(suite/run.uptime_class) classifies from it."""
|
|
951
|
+
return text if len(text) <= head + tail else f"{text[:head]} … {text[-tail:]}"
|
|
952
|
+
|
|
953
|
+
|
|
909
954
|
LOG_LOCKED = "sqlite3.OperationalError: database is locked"
|
|
910
955
|
|
|
911
956
|
|
|
@@ -930,6 +975,11 @@ def _selftest() -> None:
|
|
|
930
975
|
assert not llm_log_failed_only("", tb) # locked before the call
|
|
931
976
|
assert not llm_log_failed_only("half an ans", "openai.APIError: stream cut\n")
|
|
932
977
|
assert not llm_log_failed_only("pong", tb + "Error: something after\n")
|
|
978
|
+
# A long error keeps its status (the start) and its last words (the end), and no image.
|
|
979
|
+
long = "Error: Error code: 429 - " + "x" * 900 + " retry later"
|
|
980
|
+
assert clip(long).startswith("Error: Error code: 429") and clip(long).endswith("retry later")
|
|
981
|
+
assert len(clip(long)) == 503 and clip("short") == "short"
|
|
982
|
+
assert "AAAA" not in clip(redact("Error code: 400 - data:image/png;base64," + "A" * 900))
|
|
933
983
|
ok = (b'event: x\ndata: {"type":"response.output_text.delta","delta":"re"}\n\n'
|
|
934
984
|
b'data: {"type":"response.completed","response":{"model":"m","usage":{"total_tokens":3},'
|
|
935
985
|
b'"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"ready"}]}]}}\n')
|