@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +170 -44
- package/ui/bench.js +6 -3
package/gateway.py
CHANGED
|
@@ -73,10 +73,40 @@ the seats' answers: its job is to reconcile what the seats said, not to look
|
|
|
73
73
|
again, so any model can merge and a disagreement about the picture is
|
|
74
74
|
reported rather than settled.
|
|
75
75
|
|
|
76
|
+
OTHER FORMATS, AND STREAMING
|
|
77
|
+
----------------------------
|
|
78
|
+
The same panel answers Anthropic Messages (/v1/messages), OpenAI Responses
|
|
79
|
+
(/v1/responses) and legacy completions (/v1/completions), Ollama (/api/chat,
|
|
80
|
+
/api/generate, /api/tags, /api/version, /api/show) and Gemini
|
|
81
|
+
(/v1beta/models/<collection>:generateContent and :streamGenerateContent):
|
|
82
|
+
metrics/formats.py turns each request into OpenAI messages and the answer
|
|
83
|
+
back into the format's reply. A panel refuses a request carrying tools,
|
|
84
|
+
tool calls or tool results (400): its text is not a tool call.
|
|
85
|
+
|
|
86
|
+
ROUTE MODE
|
|
87
|
+
----------
|
|
88
|
+
For agent clients (Claude Code, Codex, opencode, Cline...): `:route` on the
|
|
89
|
+
model, `"bench": {"reply": "route"}`, or a collection's `reply`. No panel:
|
|
90
|
+
one member gets the request as sent, tools, tool_choice, response_format,
|
|
91
|
+
images and all, and its answer is relayed in the request's format, streamed
|
|
92
|
+
or not (route_plan, route_turn, Handler._route). The member is picked by its
|
|
93
|
+
record with some rotation, kept for the whole conversation (fingerprint),
|
|
94
|
+
and replaced by the next if it fails before its first piece. Each turn is
|
|
95
|
+
in the ledger and runs/gateway/routes.jsonl; each conversation is one
|
|
96
|
+
unjudged offload per model that answered it, a verdict optional. GATEWAY.md
|
|
97
|
+
"Route mode" has the decisions and the limits.
|
|
98
|
+
|
|
99
|
+
Asked to stream, the reply's 200 goes as soon as the panel is seated (a
|
|
100
|
+
refusal before that keeps its status), keep-alives go while the seats
|
|
101
|
+
think, each seat's section goes as that seat finishes (a merged reply when
|
|
102
|
+
the merge is done), and an error after the 200 goes into the stream in the
|
|
103
|
+
format's own shape (Handler._stream).
|
|
104
|
+
|
|
76
105
|
WHAT IT IS NOT
|
|
77
106
|
--------------
|
|
78
|
-
A
|
|
79
|
-
|
|
107
|
+
A proxy to a model of the caller's choosing: route mode picks a collection's
|
|
108
|
+
member by its record, and a tool that wants one model by name calls that
|
|
109
|
+
model's endpoint (or Open WebUI) directly. Seats are called with `llm -m`, exactly
|
|
80
110
|
as offloads always were, or the built-in client (BENCH_CLIENT), so endpoints,
|
|
81
111
|
headers and per-model options all come from the same registry. Full prompts
|
|
82
112
|
and answers go to `runs/gateway/`, gitignored, with the name of the gateway
|
|
@@ -89,16 +119,20 @@ CORS). The dashboard answers these same routes on its own port.
|
|
|
89
119
|
from __future__ import annotations
|
|
90
120
|
|
|
91
121
|
import datetime as dt
|
|
122
|
+
import hashlib
|
|
92
123
|
import json
|
|
93
124
|
import os
|
|
94
125
|
import pathlib
|
|
126
|
+
import queue
|
|
127
|
+
import random
|
|
95
128
|
import re
|
|
96
129
|
import secrets
|
|
97
130
|
import subprocess
|
|
98
131
|
import sys
|
|
132
|
+
import threading
|
|
99
133
|
import time
|
|
100
134
|
import urllib.parse
|
|
101
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
135
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
102
136
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
103
137
|
|
|
104
138
|
ROOT = pathlib.Path(__file__).resolve().parent
|
|
@@ -114,7 +148,9 @@ sys.path.insert(0, str(ROOT / "suite"))
|
|
|
114
148
|
import bench_collections # noqa: E402
|
|
115
149
|
import client # noqa: E402
|
|
116
150
|
import decide # noqa: E402
|
|
151
|
+
import formats # noqa: E402
|
|
117
152
|
import guard # noqa: E402
|
|
153
|
+
import model_roles # noqa: E402
|
|
118
154
|
import offload # noqa: E402
|
|
119
155
|
import route # noqa: E402
|
|
120
156
|
|
|
@@ -145,6 +181,13 @@ URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
|
|
|
145
181
|
BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
|
|
146
182
|
HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
|
|
147
183
|
SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
|
|
184
|
+
QUERY_KEY = re.compile(r"([?&](?:key|api_key|access_token)=)[^&\s'\"]*", re.I)
|
|
185
|
+
# A streaming reply: how long a request may take to be seated before its 200
|
|
186
|
+
# goes anyway (a refusal before then keeps its status), and how often a
|
|
187
|
+
# keep-alive goes while the seats think. Idle timeouts in clients and proxies
|
|
188
|
+
# start around 30 seconds.
|
|
189
|
+
STREAM_GRACE = 1.0
|
|
190
|
+
KEEPALIVE = 10.0
|
|
148
191
|
# A merge that reads as consensus hides the dissent the bench runs on, so the
|
|
149
192
|
# merger is told to keep who said what.
|
|
150
193
|
MERGE_SYSTEM = ("Several models answered the same request. Write one answer to it from theirs: say where"
|
|
@@ -370,7 +413,18 @@ def estimate_tokens(text: str) -> int:
|
|
|
370
413
|
size, which errs high too: OpenAI's, Anthropic's and Gemini's models spend
|
|
371
414
|
up to about 1,600 on one at their usual detail. Qwen and Llama vision
|
|
372
415
|
models can spend more on a very large image; a learned error corrects that."""
|
|
373
|
-
return
|
|
416
|
+
return formats.estimate(text)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def prompt_tokens(messages: list[dict]) -> int:
|
|
420
|
+
"""The estimate for a whole request, images counted: what a reply's usage
|
|
421
|
+
and a count_tokens route say."""
|
|
422
|
+
system, prompt = flatten(messages)
|
|
423
|
+
try:
|
|
424
|
+
pictures = len(images(messages))
|
|
425
|
+
except ValueError: # refused by run_collection, which says why
|
|
426
|
+
pictures = 0
|
|
427
|
+
return estimate_tokens(f"{system}\n\n{prompt}".strip()) + IMAGE_TOKENS * pictures
|
|
374
428
|
|
|
375
429
|
|
|
376
430
|
def too_small(needed: int, aliases) -> dict[str, int]:
|
|
@@ -440,11 +494,25 @@ def merge(collection: dict, members: list[str], shape: str, whole: str, seats: l
|
|
|
440
494
|
return seat, ""
|
|
441
495
|
|
|
442
496
|
|
|
497
|
+
def section(seat: dict) -> str:
|
|
498
|
+
"""One seat's part of an every-answer reply: its heading, call id, status and answer."""
|
|
499
|
+
body = seat["answer"] or (f"_error: {seat['error'][:300]}_" if seat["error"] else
|
|
500
|
+
"_empty reply — usually a token budget, not incapacity_")
|
|
501
|
+
return f"## {seat['seat']} — {seat['alias']}\n`{seat['call']}` · {seat['status']} · {seat['seconds']}s\n\n{body}\n"
|
|
502
|
+
|
|
503
|
+
|
|
443
504
|
def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
|
|
444
|
-
reply: str = "", who: str = "local") -> tuple[str, dict]:
|
|
505
|
+
reply: str = "", who: str = "local", on_part=None) -> tuple[str, dict]:
|
|
445
506
|
"""The panel's reply as markdown, and the same seats as data for the
|
|
446
507
|
response's `bench` field. `reply`: the request's mode, "" for the
|
|
447
|
-
collection's own. `who`: the gateway key that asked, for the call log.
|
|
508
|
+
collection's own. `who`: the gateway key that asked, for the call log.
|
|
509
|
+
|
|
510
|
+
`on_part`, for a streaming caller: called with each piece of an
|
|
511
|
+
every-answer reply as it is ready, the heading once the panel is seated,
|
|
512
|
+
each seat's section as that seat finishes, then the closing lines. The
|
|
513
|
+
pieces add up to the content, with the seats in the order they finished
|
|
514
|
+
rather than seat order. Not called for a merged reply: the merge is the
|
|
515
|
+
reply, and it comes at the end (or every answer, if it fails)."""
|
|
448
516
|
pictures = images(messages)
|
|
449
517
|
system, prompt = flatten(messages)
|
|
450
518
|
if not prompt.strip():
|
|
@@ -477,6 +545,13 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
477
545
|
# answer anyway, about a picture it never got. Skipped, and named.
|
|
478
546
|
skipped += [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
|
|
479
547
|
for a in group if pictures and (why := cannot_see(a))]
|
|
548
|
+
# A VISION SPECIALIST SITS ONLY FOR PICTURES: its role says it reads
|
|
549
|
+
# images, not that it answers text, so on a text request its seat goes to
|
|
550
|
+
# the next member or an explorer. Named in `with`, it sits anyway.
|
|
551
|
+
if not pictures:
|
|
552
|
+
roles = model_roles.load()["models"]
|
|
553
|
+
skipped += [{"seat": "member", "alias": a, "why": "a vision specialist: sits only when the request"
|
|
554
|
+
" carries images"} for a in members if roles.get(a) == "vision"]
|
|
480
555
|
unseen = {k["alias"] for k in skipped}
|
|
481
556
|
sitting = [a for a in members if a not in unseen]
|
|
482
557
|
nominated = [a for a in nominated if a not in unseen]
|
|
@@ -522,27 +597,39 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
522
597
|
+ "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
|
|
523
598
|
panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
|
|
524
599
|
model = bench_collections.PREFIX + name
|
|
525
|
-
with ThreadPoolExecutor(max_workers=len(seats)) as pool:
|
|
526
|
-
results = list(pool.map(lambda s: ask_seat(s[2], system, prompt, messages if pictures else None), seats))
|
|
527
|
-
|
|
528
600
|
task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
|
|
529
|
-
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
530
|
-
record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
531
|
-
"model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
|
|
532
|
-
"prompt": prompt, "seats": [], **({"skipped": skipped} if skipped else {})}
|
|
533
601
|
out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
|
|
534
602
|
f" explorers · explorer shape {shape} ({how})\n"]
|
|
535
603
|
if skipped:
|
|
536
604
|
out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
605
|
+
emit = None if merged else on_part
|
|
606
|
+
if emit:
|
|
607
|
+
emit("\n".join(out))
|
|
608
|
+
done: dict[int, dict] = {}
|
|
609
|
+
with ThreadPoolExecutor(max_workers=len(seats)) as pool:
|
|
610
|
+
asked = {pool.submit(ask_seat, alias, system, prompt, messages if pictures else None): i
|
|
611
|
+
for i, (_key, _label, alias) in enumerate(seats)}
|
|
612
|
+
# Each seat is logged the moment it answers, so explorer rotation
|
|
613
|
+
# counts it even if a slower seat (or the caller) never finishes.
|
|
614
|
+
for future in as_completed(asked):
|
|
615
|
+
i = asked[future]
|
|
616
|
+
key, label, alias = seats[i]
|
|
617
|
+
done[i] = record_seat(task, f"{panel_id}.{key}", label, alias, future.result())
|
|
618
|
+
if emit:
|
|
619
|
+
emit("\n" + section(done[i]))
|
|
620
|
+
|
|
621
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
622
|
+
record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
623
|
+
"model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
|
|
624
|
+
"prompt": prompt, "seats": [done[i] for i in range(len(seats))],
|
|
625
|
+
**({"skipped": skipped} if skipped else {})}
|
|
626
|
+
out += [section(s) for s in record["seats"]]
|
|
627
|
+
footer = ("---\nRecord a verdict per seat once checked against the code:\n"
|
|
628
|
+
"`POST /bench/verdict` on this same server (see `bench.verdict`), or"
|
|
629
|
+
" `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
|
|
630
|
+
out.append(footer)
|
|
631
|
+
if emit:
|
|
632
|
+
emit("\n" + footer)
|
|
546
633
|
content = "\n".join(out)
|
|
547
634
|
# Beyond the OpenAI fields, so a caller reads seats without parsing the
|
|
548
635
|
# markdown. Clients that do not know it ignore it.
|
|
@@ -577,6 +664,297 @@ def models() -> list[str]:
|
|
|
577
664
|
return [bench_collections.PREFIX + n for n in bench_collections.load()]
|
|
578
665
|
|
|
579
666
|
|
|
667
|
+
def listed(who: str) -> list[tuple[str, dict]]:
|
|
668
|
+
"""(model name, what it is for) for every collection key `who` may call,
|
|
669
|
+
each three times: as set, and in each other reply mode, so a client that
|
|
670
|
+
offers only listed models (an agent's model picker above all, for
|
|
671
|
+
`:route`) can still pick any. What GET /v1/models, /api/tags and
|
|
672
|
+
/v1beta/models list, each in its own shape."""
|
|
673
|
+
s, rows = bench_collections.settings(), []
|
|
674
|
+
for n, c in bench_collections.load().items():
|
|
675
|
+
if not guard.may_call(who, n): # a key limited to some collections sees those
|
|
676
|
+
continue
|
|
677
|
+
d = bench_collections.described(n, c, s)
|
|
678
|
+
rows.append((bench_collections.PREFIX + n, d))
|
|
679
|
+
rows += [(f"{bench_collections.PREFIX}{n}:{other}", {**d, "reply": other})
|
|
680
|
+
for other in bench_collections.REPLIES if other != d["reply"]]
|
|
681
|
+
return rows
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def bench_headers(bench: dict) -> dict:
|
|
685
|
+
"""The panel's call ids as headers, for a format with no room for the
|
|
686
|
+
bench field in its body (Gemini): X-Bench-Panel, and X-Bench-Seats as
|
|
687
|
+
JSON, without the answers (they are in the reply)."""
|
|
688
|
+
seats = bench.get("seats", []) + ([bench["merger"]] if bench.get("merger") else [])
|
|
689
|
+
return {"X-Bench-Panel": str(bench.get("panel", "")),
|
|
690
|
+
"X-Bench-Seats": json.dumps([{k: s.get(k) for k in ("call", "seat", "alias", "status")} for s in seats])}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def refusal(exc: Exception) -> tuple[int, str]:
|
|
694
|
+
"""The status and message for a panel that could not run."""
|
|
695
|
+
if isinstance(exc, TooLarge):
|
|
696
|
+
return 413, str(exc)
|
|
697
|
+
if isinstance(exc, ValueError):
|
|
698
|
+
return 400, str(exc)
|
|
699
|
+
if isinstance(exc, LookupError):
|
|
700
|
+
return 503, str(exc)
|
|
701
|
+
return 500, f"the panel failed ({type(exc).__name__}: {exc})"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# ---- route mode: one member, tools and all (GATEWAY.md "Route mode") --------
|
|
705
|
+
|
|
706
|
+
# A whole agent turn; a file written as one tool call can stream for minutes.
|
|
707
|
+
ROUTE_TIMEOUT = 600
|
|
708
|
+
# How long a member may take to its first piece (text, reasoning or a tool
|
|
709
|
+
# call) before the next is tried. Reasoning streams count, so only a model
|
|
710
|
+
# that sends nothing at all waits this long.
|
|
711
|
+
FIRST_TOKEN = 90
|
|
712
|
+
ROUTE_TRIES = 3 # members tried for one turn before it fails
|
|
713
|
+
# A streamed turn's 200 waits this long for the first piece, so a turn that
|
|
714
|
+
# fails everywhere quickly keeps its status (a client retries a 429 or 503 it
|
|
715
|
+
# can see, not an error inside a 200); after it, keep-alives.
|
|
716
|
+
ROUTE_GRACE = 15.0
|
|
717
|
+
STICKY_TTL = 3600 # seconds idle before a conversation is picked afresh
|
|
718
|
+
ROTATE = 0.25 # share of new conversations given to the member with the oldest record
|
|
719
|
+
_STICKY: dict[str, dict] = {} # ponytail: in memory, so a restart re-picks; a file if that ever matters
|
|
720
|
+
_STICKY_LOCK = threading.Lock()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def cannot_call_tools(alias: str) -> str:
|
|
724
|
+
"""Why a request with tools cannot be routed to `alias`, or "" when it
|
|
725
|
+
can. As cannot_see, the catalog decides (the model's listed parameters,
|
|
726
|
+
your Tool calling override in its model popup, a probe that saw a call),
|
|
727
|
+
and a model it knows nothing about is not routed to: one that cannot call
|
|
728
|
+
tools answers in prose, and the agent waits for a call that never comes."""
|
|
729
|
+
import catalog
|
|
730
|
+
rec = catalog.info(alias)
|
|
731
|
+
if rec.get("tools"):
|
|
732
|
+
return ""
|
|
733
|
+
if rec.get("tools") is None:
|
|
734
|
+
return "the catalog does not say whether it calls tools; tick Tool calling in its model popup if it does"
|
|
735
|
+
return f"does not call tools (says {rec['sources'].get('tools', 'the catalog')})"
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def route_tokens(ask: dict) -> int:
|
|
739
|
+
"""The size check's estimate for a routed request: the messages as a
|
|
740
|
+
panel counts them, plus the tool calls in them and the tool definitions,
|
|
741
|
+
which an agent's requests are full of."""
|
|
742
|
+
extra = [[m["tool_calls"] for m in ask["messages"] if m.get("tool_calls")], ask["upstream"].get("tools") or []]
|
|
743
|
+
return prompt_tokens(ask["messages"]) + (estimate_tokens(json.dumps(extra)) if any(extra) else 0)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def fingerprint(name: str, who: str, messages: list[dict]) -> str:
|
|
747
|
+
"""What keeps an agent's conversation on one model from turn to turn:
|
|
748
|
+
the collection, the gateway key, the system text and the first user
|
|
749
|
+
message, which every turn sends again unchanged. A side task with its
|
|
750
|
+
own system prompt (titling the chat, a subagent) is its own conversation."""
|
|
751
|
+
system = "\n".join(formats.text_of(m.get("content")) for m in messages if m.get("role") in ("system", "developer"))
|
|
752
|
+
first = next((formats.text_of(m.get("content")) for m in messages if m.get("role") == "user"), "")
|
|
753
|
+
return hashlib.sha256("\0".join((name, who, system, first)).encode()).hexdigest()[:24]
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def route_order(aliases: list[str]) -> list[str]:
|
|
757
|
+
"""Best record first: useful verdicts counted, as the merger is chosen
|
|
758
|
+
(one lucky verdict must not outrank a long good record), then verified
|
|
759
|
+
suite findings, then a connection already paid for. Members cooling down
|
|
760
|
+
after a rate limit or failing their probes go last. Now and then
|
|
761
|
+
(ROTATE) the member whose record is oldest goes first instead, so one
|
|
762
|
+
that stops being picked still gets turns, and with them a record."""
|
|
763
|
+
try:
|
|
764
|
+
used = offload.summary()
|
|
765
|
+
except Exception: # noqa: BLE001
|
|
766
|
+
used = {}
|
|
767
|
+
try:
|
|
768
|
+
found = route.suite_recall()
|
|
769
|
+
except Exception: # noqa: BLE001
|
|
770
|
+
found = {}
|
|
771
|
+
try:
|
|
772
|
+
paid = route.preferred_connections()
|
|
773
|
+
tags = {a: route.full_tag_of(a) for a in aliases}
|
|
774
|
+
except Exception: # noqa: BLE001
|
|
775
|
+
paid, tags = set(), {}
|
|
776
|
+
ranked = sorted(aliases, reverse=True, key=lambda a: (used.get(a, {}).get("useful", 0),
|
|
777
|
+
found.get(a, (0, 0, 0))[0], tags.get(a) in paid))
|
|
778
|
+
cooling = retiring()
|
|
779
|
+
fresh = [a for a in ranked if a not in cooling]
|
|
780
|
+
if len(fresh) > 1 and random.random() < ROTATE:
|
|
781
|
+
stalest = min(fresh, key=lambda a: used.get(a, {}).get("last_date") or "")
|
|
782
|
+
fresh.remove(stalest)
|
|
783
|
+
fresh.insert(0, stalest)
|
|
784
|
+
return fresh + [a for a in ranked if a in cooling]
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def route_plan(name: str, ask: dict, who: str) -> dict:
|
|
788
|
+
"""The members that may take this turn, in the order to try them, and
|
|
789
|
+
the conversation it belongs to. A conversation under way keeps its model
|
|
790
|
+
first; a new one is ranked by route_order. Excluded, and named with why,
|
|
791
|
+
as a panel excludes them: retired and ad hoc members, vision specialists
|
|
792
|
+
on a request without images, members reached
|
|
793
|
+
through another API than chat completions, and those that cannot call
|
|
794
|
+
tools (when the request has tools), see (when it has images) or read it.
|
|
795
|
+
Raises as run_collection does: TooLarge, ValueError, LookupError."""
|
|
796
|
+
import endpoints
|
|
797
|
+
from run import ad_hoc_models
|
|
798
|
+
members = list(dict.fromkeys(m for m in bench_collections.load()[name].get("members") or []
|
|
799
|
+
if isinstance(m, str) and m))
|
|
800
|
+
if not members:
|
|
801
|
+
raise LookupError(f"collection {name!r} has no members to route to")
|
|
802
|
+
pictures, tools = images(ask["messages"]), bool(ask["upstream"].get("tools"))
|
|
803
|
+
needed = route_tokens(ask) + IMAGE_TOKENS * len(pictures)
|
|
804
|
+
gone, held, small = retired(), ad_hoc_models(), too_small(needed, members)
|
|
805
|
+
roles = model_roles.load()["models"]
|
|
806
|
+
skipped, fit = [], []
|
|
807
|
+
for a in members:
|
|
808
|
+
api = endpoints.entry_of(a).get("api") or "chat"
|
|
809
|
+
why = (retired_why(gone[a]) if a in gone else
|
|
810
|
+
"ad hoc (expensive or capped), so never routed to automatically" if a in held else
|
|
811
|
+
"a vision specialist: routed to only when the request carries images"
|
|
812
|
+
if roles.get(a) == "vision" and not pictures else
|
|
813
|
+
f"reached through the {api} API; route mode passes requests on to chat completions only" if api != "chat"
|
|
814
|
+
else (tools and cannot_call_tools(a)) or (pictures and cannot_see(a)) or
|
|
815
|
+
(f"its context window ({small[a]:,} tokens) is under this request (about {needed:,})" if a in small else ""))
|
|
816
|
+
(skipped.append({"alias": a, "why": why}) if why else fit.append(a))
|
|
817
|
+
if not fit:
|
|
818
|
+
detail = "; ".join(f"{k['alias']} {k['why']}" for k in skipped)
|
|
819
|
+
if all(k["alias"] in small for k in skipped):
|
|
820
|
+
raise TooLarge(f"no member of bench/{name} can read this request: {detail}. Trim it, or choose a"
|
|
821
|
+
" collection whose members have larger windows (GET /v1/models lists each one's).")
|
|
822
|
+
raise ValueError(f"no member of bench/{name} can take this request: {detail}")
|
|
823
|
+
key, now = fingerprint(name, who, ask["messages"]), time.monotonic()
|
|
824
|
+
with _STICKY_LOCK:
|
|
825
|
+
for k in [k for k, c in _STICKY.items() if now - c["at"] > STICKY_TTL]:
|
|
826
|
+
del _STICKY[k]
|
|
827
|
+
conv = _STICKY.get(key)
|
|
828
|
+
if conv is None or conv["alias"] not in fit: # new, or its model cannot take this turn (an image, say)
|
|
829
|
+
ranked = route_order(fit)
|
|
830
|
+
first = next((formats.text_of(m.get("content")) for m in ask["messages"] if m.get("role") == "user"), "")
|
|
831
|
+
fresh = {"conv": f"r{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}", "turns": 0, "models": [],
|
|
832
|
+
"task": " ".join(first.split())[:90], "alias": ranked[0], "order": ranked, "at": now}
|
|
833
|
+
with _STICKY_LOCK:
|
|
834
|
+
# Changed in place, or the first of two simultaneous new turns
|
|
835
|
+
# kept: every turn of one conversation shares one record.
|
|
836
|
+
if conv is None:
|
|
837
|
+
conv = _STICKY.setdefault(key, fresh)
|
|
838
|
+
else:
|
|
839
|
+
conv.update(alias=ranked[0], order=ranked, at=now)
|
|
840
|
+
order = [conv["alias"]] + [a for a in conv["order"] if a != conv["alias"] and a in fit]
|
|
841
|
+
return {"conv": conv, "order": order + [a for a in fit if a not in order], "skipped": skipped,
|
|
842
|
+
"images": bool(pictures)}
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _fit(upstream: dict, alias: str) -> dict:
|
|
846
|
+
"""The request's fields for `alias`: an output budget over the model's
|
|
847
|
+
known maximum is cut to it rather than refused (Claude Code asks every
|
|
848
|
+
model for 32,000)."""
|
|
849
|
+
import catalog
|
|
850
|
+
cap = catalog.info(alias).get("max_output")
|
|
851
|
+
return {k: min(v, cap) if k in ("max_tokens", "max_completion_tokens") and isinstance(v, int)
|
|
852
|
+
and isinstance(cap, int) and cap > 0 else v for k, v in upstream.items()}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def route_turn(name: str, plan: dict, ask: dict, who: str, emit) -> None:
|
|
856
|
+
"""One agent turn: the plan's members in order until one answers.
|
|
857
|
+
|
|
858
|
+
The answering member's chunks go to emit("chunk", chunk), held back
|
|
859
|
+
until the first piece of its answer (text or a tool call; reasoning
|
|
860
|
+
before it is held with it), so a member that fails before then (a 429, a
|
|
861
|
+
5xx, a pacing refusal, nothing in FIRST_TOKEN seconds, an empty reply
|
|
862
|
+
even after reasoning) is replaced by the next without the caller seeing
|
|
863
|
+
a thing; keep-alives cover the wait. emit("live",
|
|
864
|
+
alias) says who it is. Then emit("done", bench), or emit("error",
|
|
865
|
+
(status, message)) when every member tried failed, or the one under way
|
|
866
|
+
failed part way, which no other member can take up.
|
|
867
|
+
|
|
868
|
+
Every call reaches the usage ledger (purpose "route"). Every turn is a
|
|
869
|
+
line in runs/gateway/routes.jsonl, without the prompt (an agent resends
|
|
870
|
+
its whole conversation each turn). A conversation is one unjudged
|
|
871
|
+
offload per model that answered it, not one per turn (GATEWAY.md says why)."""
|
|
872
|
+
conv, order, tried = plan["conv"], plan["order"][:ROUTE_TRIES], []
|
|
873
|
+
for n, alias in enumerate(order):
|
|
874
|
+
held, live = [], [False]
|
|
875
|
+
last = n == len(order) - 1
|
|
876
|
+
|
|
877
|
+
def on_chunk(chunk, held=held, live=live, turn=formats.Turn(), alias=alias):
|
|
878
|
+
_, anything = turn.feed(chunk)
|
|
879
|
+
if live[0]:
|
|
880
|
+
return emit("chunk", chunk)
|
|
881
|
+
held.append(chunk)
|
|
882
|
+
if anything:
|
|
883
|
+
live[0] = True
|
|
884
|
+
emit("live", alias)
|
|
885
|
+
for c in held:
|
|
886
|
+
emit("chunk", c)
|
|
887
|
+
res = client.chat(alias, "", timeout=ROUTE_TIMEOUT, messages=ask["messages"], body=_fit(ask["upstream"], alias),
|
|
888
|
+
on_chunk=on_chunk, first_token=FIRST_TOKEN, retries=None if last else 0, purpose="route")
|
|
889
|
+
error = res["error"] or ("" if not res["timed_out"] else "timed out part way" if live[0]
|
|
890
|
+
else f"nothing in {FIRST_TOKEN}s")
|
|
891
|
+
if plan["images"]:
|
|
892
|
+
error = redact(error) # an upstream may echo an image back
|
|
893
|
+
if error:
|
|
894
|
+
try:
|
|
895
|
+
import learn
|
|
896
|
+
learn.record(alias, error, "gateway") # a rate limit cools the model for the next pick
|
|
897
|
+
except Exception: # noqa: BLE001
|
|
898
|
+
pass
|
|
899
|
+
if not error and not live[0] and not last:
|
|
900
|
+
error = "an empty reply" # nothing was sent: another member may do better
|
|
901
|
+
if error and not live[0]:
|
|
902
|
+
tried.append({"alias": alias, "error": error[-300:]})
|
|
903
|
+
continue
|
|
904
|
+
if not live[0]: # the last member's empty reply is its answer
|
|
905
|
+
emit("live", alias)
|
|
906
|
+
for c in held:
|
|
907
|
+
emit("chunk", c)
|
|
908
|
+
with _STICKY_LOCK:
|
|
909
|
+
fresh = alias not in conv["models"]
|
|
910
|
+
if fresh:
|
|
911
|
+
conv["models"].append(alias)
|
|
912
|
+
conv.update(alias=alias, at=time.monotonic(), turns=conv["turns"] + 1)
|
|
913
|
+
call, turn_no = f"{conv['conv']}.{conv['models'].index(alias) + 1}", conv["turns"]
|
|
914
|
+
status = "error" if error else "ok" if res["text"] or res["tool_calls"] else "empty"
|
|
915
|
+
model = bench_collections.PREFIX + name
|
|
916
|
+
if fresh:
|
|
917
|
+
offload.add(alias, f"{conv['task']} ({model}:route)", None,
|
|
918
|
+
f"route conversation; first turn {status} in {res['seconds']}s", call)
|
|
919
|
+
_log_route(conv, turn_no, call, model, who, alias, status, res, tried, error)
|
|
920
|
+
if error:
|
|
921
|
+
return emit("error", (res["status"] if res["status"] >= 400 else 502,
|
|
922
|
+
f"{alias} failed part way through the turn: {error[:300]}"))
|
|
923
|
+
# SHADOWS GO HERE, when they come: an opt-in that sends this same turn
|
|
924
|
+
# (ask, minus the caller's stream) to other members in the background,
|
|
925
|
+
# for evaluation only, after the caller has its answer. Each would be
|
|
926
|
+
# its own client.chat (purpose "shadow"), recorded as an unjudged
|
|
927
|
+
# offload on this conversation's call id with a suffix; nothing the
|
|
928
|
+
# caller sees waits for them or changes.
|
|
929
|
+
return emit("done", {"collection": model, "reply": "route", "conversation": conv["conv"], "call": call,
|
|
930
|
+
"alias": alias, "turn": turn_no, **({"failed_over": tried} if tried else {}),
|
|
931
|
+
**({"skipped": plan["skipped"]} if plan["skipped"] else {}),
|
|
932
|
+
"verdict": 'optional, once per conversation: POST /bench/verdict {"call", "useful", "note"}'})
|
|
933
|
+
_log_route(conv, conv["turns"], "", bench_collections.PREFIX + name, who, "", "error", None, tried,
|
|
934
|
+
"every member failed")
|
|
935
|
+
# Every member rate limited (or held back by pacing): 429, so the caller
|
|
936
|
+
# backs off and tries again, as it would for one provider.
|
|
937
|
+
limited = all("Error code: 429" in t["error"] or "rate limit" in t["error"].lower() for t in tried)
|
|
938
|
+
emit("error", (429 if tried and limited else 502,
|
|
939
|
+
f"no member of bench/{name} answered: " + "; ".join(f"{t['alias']}: {t['error'][:200]}" for t in tried)))
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _log_route(conv: dict, turn: int, call: str, model: str, who: str, alias: str, status: str, res: dict | None,
|
|
943
|
+
tried: list[dict], error: str) -> None:
|
|
944
|
+
"""One turn's line in runs/gateway/routes.jsonl, which the dashboard's
|
|
945
|
+
Gateway card reads for its route conversations."""
|
|
946
|
+
import ledger
|
|
947
|
+
res = res or {}
|
|
948
|
+
line = {"conversation": conv["conv"], "call": call, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
949
|
+
"model": model, "key": who, "alias": alias, "turn": turn, "task": conv["task"], "status": status,
|
|
950
|
+
"seconds": res.get("seconds"), "first_token_s": res.get("first_token_s"),
|
|
951
|
+
"tool_calls": res.get("tool_calls", 0), "finish": res.get("finish", ""), **ledger.tokens(res.get("usage") or {}),
|
|
952
|
+
**({"tried": tried} if tried else {}), **({"error": error[-300:]} if error else {})}
|
|
953
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
954
|
+
with (LOG_DIR / "routes.jsonl").open("a", encoding="utf-8") as fh:
|
|
955
|
+
fh.write(json.dumps(line, ensure_ascii=False) + "\n")
|
|
956
|
+
|
|
957
|
+
|
|
580
958
|
class Handler(BaseHTTPRequestHandler):
|
|
581
959
|
"""The gateway's routes. serve.py's Handler subclasses this to answer them
|
|
582
960
|
on the dashboard's port too, so the checks use the port actually served."""
|
|
@@ -592,8 +970,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
592
970
|
self.end_headers()
|
|
593
971
|
self.wfile.write(body)
|
|
594
972
|
|
|
595
|
-
def _error(self, code: int, message: str, headers: dict | None = None) -> None:
|
|
596
|
-
|
|
973
|
+
def _error(self, code: int, message: str, headers: dict | None = None, fmt: str = "chat") -> None:
|
|
974
|
+
"""A refusal, in the shape the route's clients read (metrics/formats.py)."""
|
|
975
|
+
self._json(code, formats.INBOUND[fmt].error(code, message), headers=headers)
|
|
976
|
+
|
|
977
|
+
def _query_key(self, fmt: str) -> str | None:
|
|
978
|
+
"""A Gemini route's ?key=, as Google's REST examples send it; no other
|
|
979
|
+
route takes a key in its URL, where proxies and histories keep it."""
|
|
980
|
+
if fmt != "gemini":
|
|
981
|
+
return None
|
|
982
|
+
return urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("key", [None])[0]
|
|
597
983
|
|
|
598
984
|
def end_headers(self):
|
|
599
985
|
# CORS for a browser app on an allowed origin, on the gateway's routes
|
|
@@ -603,6 +989,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
603
989
|
if headers is not None and guard.gateway_route((getattr(self, "path", "") or "").split("?", 1)[0]) \
|
|
604
990
|
and (origin := guard.cors_origin(headers, self.server.server_port)):
|
|
605
991
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
992
|
+
self.send_header("Access-Control-Expose-Headers",
|
|
993
|
+
"X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model")
|
|
606
994
|
self.send_header("Vary", "Origin")
|
|
607
995
|
super().end_headers()
|
|
608
996
|
|
|
@@ -630,45 +1018,47 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
630
1018
|
self.end_headers()
|
|
631
1019
|
|
|
632
1020
|
def do_GET(self): # noqa: N802
|
|
1021
|
+
path = self.path.split("?", 1)[0].rstrip("/")
|
|
1022
|
+
fmt = formats.format_of(path)
|
|
633
1023
|
try:
|
|
634
1024
|
guard.check(self.headers, self.server.server_port, post=False, max_body=0)
|
|
635
|
-
self.who = guard.gateway_caller(self.headers)
|
|
1025
|
+
self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
|
|
636
1026
|
except guard.Refused as r:
|
|
637
|
-
return self._error(r.status, str(r), r.headers)
|
|
638
|
-
if
|
|
1027
|
+
return self._error(r.status, str(r), r.headers, fmt)
|
|
1028
|
+
if path in ("/v1/models", "/models"):
|
|
639
1029
|
# Beyond the OpenAI fields: what each collection is for, so an
|
|
640
1030
|
# orchestrator can choose one by task. Clients ignore extra keys.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
self._error(404, "GET /v1/models is the only GET here")
|
|
1031
|
+
return self._json(200, {"object": "list", "data": [
|
|
1032
|
+
{"id": n, "object": "model", "owned_by": "benchwarmers", **d} for n, d in listed(self.who)]})
|
|
1033
|
+
if path == "/api/tags":
|
|
1034
|
+
return self._json(200, formats.ollama_tags(listed(self.who)))
|
|
1035
|
+
if path == "/api/version":
|
|
1036
|
+
return self._json(200, {"version": formats.OLLAMA_VERSION})
|
|
1037
|
+
if path == "/v1beta/models":
|
|
1038
|
+
return self._json(200, formats.gemini_models(listed(self.who)))
|
|
1039
|
+
if path.startswith("/v1beta/models/"):
|
|
1040
|
+
name = urllib.parse.unquote(path[len("/v1beta/models/"):])
|
|
1041
|
+
row = dict(listed(self.who)).get(name)
|
|
1042
|
+
return self._json(200, formats.gemini_model(name, row)) if row else \
|
|
1043
|
+
self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
|
|
1044
|
+
self._error(404, "GET /v1/models, /api/tags, /api/version or /v1beta/models", fmt=fmt)
|
|
656
1045
|
|
|
657
1046
|
def do_POST(self): # noqa: N802
|
|
658
1047
|
# Without this any web page could spend model calls, seat ad hoc
|
|
659
1048
|
# models through `with`, or write verdicts (metrics/guard.py).
|
|
1049
|
+
path = self.path.split("?", 1)[0].rstrip("/")
|
|
1050
|
+
fmt = formats.format_of(path)
|
|
660
1051
|
try:
|
|
661
1052
|
length = guard.check(self.headers, self.server.server_port, post=True, max_body=MAX_BODY, cors=True)
|
|
662
|
-
self.who = guard.gateway_caller(self.headers)
|
|
1053
|
+
self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
|
|
663
1054
|
except guard.Refused as r:
|
|
664
|
-
return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers)
|
|
1055
|
+
return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers, fmt)
|
|
665
1056
|
try:
|
|
666
1057
|
body = json.loads(self.rfile.read(length) or b"{}")
|
|
667
1058
|
except ValueError:
|
|
668
|
-
return self._error(400, "body is not JSON")
|
|
1059
|
+
return self._error(400, "body is not JSON", fmt=fmt)
|
|
669
1060
|
if not isinstance(body, dict):
|
|
670
|
-
return self._error(400, "body must be a JSON object")
|
|
671
|
-
path = self.path.split("?", 1)[0].rstrip("/")
|
|
1061
|
+
return self._error(400, "body must be a JSON object", fmt=fmt)
|
|
672
1062
|
if path == "/bench/verdict":
|
|
673
1063
|
if not isinstance(body.get("useful"), bool):
|
|
674
1064
|
return self._error(400, '"useful" must be true or false')
|
|
@@ -681,66 +1071,202 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
681
1071
|
except KeyError as exc:
|
|
682
1072
|
return self._error(404, exc.args[0])
|
|
683
1073
|
return self._json(200, {"ok": True, "recorded": e})
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1074
|
+
# Each format's request becomes one Ask (metrics/formats.py); a part
|
|
1075
|
+
# that would be lost on the way, tools above all, is refused with why.
|
|
1076
|
+
gemini = formats.gemini_route(path)
|
|
1077
|
+
try:
|
|
1078
|
+
if gemini and gemini[1] == "countTokens":
|
|
1079
|
+
ask = formats.gemini_ask(body.get("generateContentRequest") or body, gemini[0])
|
|
1080
|
+
return self._json(200, {"totalTokens": prompt_tokens(ask["messages"])})
|
|
1081
|
+
if gemini:
|
|
1082
|
+
if gemini[1] not in ("generateContent", "streamGenerateContent"):
|
|
1083
|
+
return self._error(404, f"POST models/<collection>:generateContent or :streamGenerateContent,"
|
|
1084
|
+
f" not :{gemini[1]}", fmt=fmt)
|
|
1085
|
+
ask = formats.gemini_ask(body, gemini[0], gemini[1] == "streamGenerateContent")
|
|
1086
|
+
elif path == "/v1/messages/count_tokens":
|
|
1087
|
+
# An estimate, as the size check makes, without calling a model.
|
|
1088
|
+
return self._json(200, {"input_tokens": prompt_tokens(formats.anthropic_ask(body)["messages"])})
|
|
1089
|
+
elif path == "/api/show":
|
|
1090
|
+
return self._show(body)
|
|
1091
|
+
elif path in formats.POST_ROUTES:
|
|
1092
|
+
ask = formats.INBOUND[fmt].ask(body)
|
|
1093
|
+
else:
|
|
1094
|
+
return self._error(404, "POST /v1/chat/completions, /v1/messages, /v1/responses, /v1/completions,"
|
|
1095
|
+
" /api/chat, /api/generate, /v1beta/models/<collection>:generateContent"
|
|
1096
|
+
" or /bench/verdict", fmt=fmt)
|
|
1097
|
+
except formats.Unsupported as exc:
|
|
1098
|
+
return self._error(400, str(exc), fmt=fmt)
|
|
1099
|
+
alt = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("alt", [""])[0]
|
|
1100
|
+
self._panel(fmt, ask, sse=alt == "sse")
|
|
1101
|
+
|
|
1102
|
+
def _show(self, body: dict) -> None:
|
|
1103
|
+
"""POST /api/show: Ollama's model card, for a collection the key may call."""
|
|
1104
|
+
name = formats.ollama_model(body.get("model") or body.get("name") or "")
|
|
1105
|
+
rows = dict(listed(self.who))
|
|
1106
|
+
row = rows.get(name) or rows.get(bench_collections.PREFIX + str(name))
|
|
1107
|
+
if not row:
|
|
1108
|
+
return self._error(404, f"model {name!r} not found; GET /api/tags lists the collections",
|
|
1109
|
+
fmt="ollama_chat")
|
|
1110
|
+
self._json(200, formats.ollama_show(name, row))
|
|
1111
|
+
|
|
1112
|
+
def _panel(self, fmt: str, ask: dict, sse: bool = True) -> None:
|
|
1113
|
+
"""One panel, for a request in any format: the collection it names,
|
|
1114
|
+
the key's scope and cap, then the panel, answered in that format."""
|
|
1115
|
+
model, suffix = bench_collections.split_reply(ask["model"])
|
|
690
1116
|
name = bench_collections.resolve(model)
|
|
691
1117
|
if name is None:
|
|
692
|
-
return self._error(400 if not model else 404, bench_collections.choose_message(model))
|
|
1118
|
+
return self._error(400 if not model else 404, bench_collections.choose_message(model), fmt=fmt)
|
|
693
1119
|
model = bench_collections.PREFIX + name
|
|
694
|
-
messages, nominated, opts = body.get("messages"), body.get("with") or [], body.get("bench") or {}
|
|
695
|
-
if not (isinstance(messages, list) and all(isinstance(m, dict) for m in messages)):
|
|
696
|
-
return self._error(400, '"messages" must be a list of message objects')
|
|
697
|
-
if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
|
|
698
|
-
return self._error(400, '"with" must be a list of model aliases')
|
|
699
|
-
if not isinstance(opts, dict) or opts.get("reply", "all") not in bench_collections.REPLIES:
|
|
700
|
-
return self._error(400, '"bench" must be an object whose "reply" is "all" or "merged"')
|
|
701
1120
|
if not guard.may_call(self.who, name):
|
|
702
1121
|
allowed = ", ".join(bench_collections.PREFIX + c for c in guard.scope(self.who) or []) or "none"
|
|
703
|
-
return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}"
|
|
1122
|
+
return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}",
|
|
1123
|
+
fmt=fmt)
|
|
704
1124
|
try:
|
|
705
1125
|
guard.spend(self.who) # counted once it names a collection it may call
|
|
706
1126
|
except guard.Refused as r:
|
|
707
|
-
return self._error(r.status, str(r), r.headers)
|
|
1127
|
+
return self._error(r.status, str(r), r.headers, fmt)
|
|
1128
|
+
reply = ask["reply"] or suffix # the body is the most specific, then the suffix, then the collection
|
|
1129
|
+
# Chat completions have always answered with the collection's name;
|
|
1130
|
+
# the other formats echo the name as asked, which their clients match.
|
|
1131
|
+
shown = model if fmt == "chat" else ask["model"]
|
|
1132
|
+
if (reply or bench_collections.load()[name].get("reply")) == "route":
|
|
1133
|
+
return self._route(fmt, name, ask, shown, sse)
|
|
1134
|
+
if ask["agentic"]:
|
|
1135
|
+
return self._error(400, formats.TOOLS, fmt=fmt)
|
|
1136
|
+
if ask["stream"]:
|
|
1137
|
+
out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
|
|
1138
|
+
return self._stream(fmt, name, ask, reply, out)
|
|
1139
|
+
started = time.monotonic()
|
|
708
1140
|
try:
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1141
|
+
content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who)
|
|
1142
|
+
except (ValueError, LookupError) as exc: # TooLarge is a ValueError
|
|
1143
|
+
return self._error(*refusal(exc), fmt=fmt)
|
|
1144
|
+
usage = (prompt_tokens(ask["messages"]), estimate_tokens(content))
|
|
1145
|
+
self._json(200, formats.INBOUND[fmt].reply(content, shown, bench, usage, time.monotonic() - started),
|
|
1146
|
+
bench_headers(bench) if fmt == "gemini" else None)
|
|
1147
|
+
|
|
1148
|
+
def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream) -> None:
|
|
1149
|
+
"""The panel as it comes, in the format's stream shape: every answer
|
|
1150
|
+
seat by seat as each finishes, or a merged reply when the merge is
|
|
1151
|
+
done. The panel runs in its own thread and this one writes, so a
|
|
1152
|
+
keep-alive goes out every KEEPALIVE seconds while the seats think. A
|
|
1153
|
+
refusal raised before anything is ready (the size check, an image no
|
|
1154
|
+
seat can see: milliseconds) still gets its status; the 200 goes once
|
|
1155
|
+
the panel is seated or after STREAM_GRACE, whichever is first, and
|
|
1156
|
+
from then on an error goes into the stream in the format's shape."""
|
|
1157
|
+
events: queue.Queue = queue.Queue()
|
|
1158
|
+
|
|
1159
|
+
def work():
|
|
1160
|
+
try:
|
|
1161
|
+
events.put(("done", run_collection(name, ask["messages"], ask["with"], reply, self.who,
|
|
1162
|
+
on_part=lambda text: events.put(("part", text)))))
|
|
1163
|
+
except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
|
|
1164
|
+
events.put(("error", exc))
|
|
1165
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1166
|
+
started = streamed = False
|
|
1167
|
+
while True:
|
|
1168
|
+
try:
|
|
1169
|
+
kind, data = events.get(timeout=KEEPALIVE if started else STREAM_GRACE)
|
|
1170
|
+
except queue.Empty:
|
|
1171
|
+
kind, data = "idle", None
|
|
1172
|
+
if kind == "error" and not started:
|
|
1173
|
+
return self._error(*refusal(data), fmt=fmt)
|
|
1174
|
+
try:
|
|
1175
|
+
if not started:
|
|
1176
|
+
self.send_response(200)
|
|
1177
|
+
self.send_header("Content-Type", out.content_type)
|
|
1178
|
+
self.send_header("Cache-Control", "no-cache")
|
|
1179
|
+
self.end_headers()
|
|
1180
|
+
self.wfile.write(out.start())
|
|
1181
|
+
started = True
|
|
1182
|
+
if kind == "idle":
|
|
1183
|
+
self.wfile.write(out.keepalive())
|
|
1184
|
+
elif kind == "part":
|
|
1185
|
+
self.wfile.write(out.text(data))
|
|
1186
|
+
streamed = True
|
|
1187
|
+
elif kind == "done":
|
|
1188
|
+
content, bench = data
|
|
1189
|
+
self.wfile.write((b"" if streamed else out.text(content)) + out.end(bench))
|
|
1190
|
+
return
|
|
1191
|
+
else:
|
|
1192
|
+
self.wfile.write(out.error(*refusal(data)))
|
|
1193
|
+
return
|
|
1194
|
+
except OSError:
|
|
1195
|
+
return # the caller left; the panel still finishes, every seat recorded
|
|
1196
|
+
|
|
1197
|
+
def _route(self, fmt: str, name: str, ask: dict, shown: str, sse: bool = True) -> None:
|
|
1198
|
+
"""Route mode: one member's answer, in the request's format. The
|
|
1199
|
+
turn runs in its own thread (route_turn) and this one writes: a
|
|
1200
|
+
whole reply once it is done, or a stream whose 200 waits for the
|
|
1201
|
+
member's first piece, or ROUTE_GRACE, whichever is first, then
|
|
1202
|
+
relays it, with a keep-alive whenever nothing went for KEEPALIVE."""
|
|
1203
|
+
try:
|
|
1204
|
+
plan = route_plan(name, ask, self.who)
|
|
1205
|
+
except (ValueError, LookupError) as exc: # TooLarge is a ValueError
|
|
1206
|
+
return self._error(*refusal(exc), fmt=fmt)
|
|
1207
|
+
events: queue.Queue = queue.Queue()
|
|
1208
|
+
|
|
1209
|
+
def work():
|
|
1210
|
+
try:
|
|
1211
|
+
route_turn(name, plan, ask, self.who, lambda kind, data=None: events.put((kind, data)))
|
|
1212
|
+
except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
|
|
1213
|
+
events.put(("error", (500, f"route mode failed ({type(exc).__name__}: {exc})")))
|
|
1214
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1215
|
+
tokens, began = route_tokens(ask), time.monotonic()
|
|
1216
|
+
headers = {"X-Bench-Conversation": plan["conv"]["conv"]}
|
|
1217
|
+
if not ask["stream"]:
|
|
1218
|
+
turn = formats.Turn()
|
|
1219
|
+
while True:
|
|
1220
|
+
kind, data = events.get()
|
|
1221
|
+
if kind == "chunk":
|
|
1222
|
+
turn.feed(data)
|
|
1223
|
+
elif kind == "done":
|
|
1224
|
+
return self._json(200, formats.route_reply(fmt, turn, shown, data, tokens, time.monotonic() - began,
|
|
1225
|
+
ask.get("custom")), {**headers, "X-Bench-Model": data["alias"]})
|
|
1226
|
+
elif kind == "error":
|
|
1227
|
+
return self._error(*data, fmt=fmt)
|
|
1228
|
+
out = formats.ROUTE[fmt](shown, tokens, sse=sse, custom=ask.get("custom"))
|
|
1229
|
+
started, wrote = False, time.monotonic()
|
|
1230
|
+
while True:
|
|
1231
|
+
try:
|
|
1232
|
+
kind, data = events.get(timeout=KEEPALIVE if started else max(0.0, ROUTE_GRACE - (time.monotonic() - began)))
|
|
1233
|
+
except queue.Empty:
|
|
1234
|
+
kind, data = "idle", None
|
|
1235
|
+
if kind == "error" and not started:
|
|
1236
|
+
return self._error(*data, fmt=fmt)
|
|
1237
|
+
if kind == "live":
|
|
1238
|
+
headers["X-Bench-Model"] = data
|
|
1239
|
+
continue
|
|
1240
|
+
try:
|
|
1241
|
+
if not started:
|
|
1242
|
+
self.send_response(200)
|
|
1243
|
+
for k, v in {"Content-Type": out.content_type, "Cache-Control": "no-cache", **headers}.items():
|
|
1244
|
+
self.send_header(k, v)
|
|
1245
|
+
self.end_headers()
|
|
1246
|
+
self.wfile.write(out.start())
|
|
1247
|
+
started = True
|
|
1248
|
+
piece = (out.chunk(data) if kind == "chunk" else out.end(data) if kind == "done" else
|
|
1249
|
+
out.error(*data) if kind == "error" else b"")
|
|
1250
|
+
if not piece and time.monotonic() - wrote >= KEEPALIVE:
|
|
1251
|
+
piece = out.keepalive()
|
|
1252
|
+
if piece:
|
|
1253
|
+
self.wfile.write(piece)
|
|
1254
|
+
wrote = time.monotonic()
|
|
1255
|
+
if kind in ("done", "error"):
|
|
1256
|
+
return
|
|
1257
|
+
except OSError:
|
|
1258
|
+
# The caller left. ponytail: the member's answer still runs to
|
|
1259
|
+
# its end and is recorded; stopping it needs a per-call STOP.
|
|
1260
|
+
return
|
|
738
1261
|
|
|
739
1262
|
def log_request(self, code="-", size="-"): # route, status, who; never a query or a key
|
|
740
1263
|
guard.access(self, code)
|
|
741
1264
|
|
|
742
1265
|
def log_message(self, fmt, *args): # quieter than the default
|
|
743
|
-
|
|
1266
|
+
# http.server's own errors quote the request line, ?key= and all
|
|
1267
|
+
# ("Bad request syntax ('GET /v1beta/models?key=...')").
|
|
1268
|
+
line = QUERY_KEY.sub(r"\1...", fmt % args)
|
|
1269
|
+
sys.stderr.write(f"{self.log_date_time_string()} {line}\n")
|
|
744
1270
|
|
|
745
1271
|
|
|
746
1272
|
def main() -> None:
|