@wendellresearch-ai/benchwarmers 0.2.2 → 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/GATEWAY.md +169 -12
- package/README.md +11 -1
- package/USAGE.md +18 -3
- package/VERSION +1 -1
- package/cli.py +1 -1
- package/gateway.py +352 -10
- package/metrics/bench_collections.py +7 -3
- package/metrics/client.py +303 -80
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +658 -95
- package/package.json +1 -1
- package/scripts/check.py +2 -0
- package/scripts/test_auth.py +2 -1
- package/scripts/test_formats.py +21 -12
- package/scripts/test_merged.py +2 -2
- package/scripts/test_native.py +188 -0
- package/scripts/test_route.py +429 -0
- package/serve.py +66 -8
- package/ui/bench.js +3 -1
package/gateway.py
CHANGED
|
@@ -80,8 +80,21 @@ The same panel answers Anthropic Messages (/v1/messages), OpenAI Responses
|
|
|
80
80
|
/api/generate, /api/tags, /api/version, /api/show) and Gemini
|
|
81
81
|
(/v1beta/models/<collection>:generateContent and :streamGenerateContent):
|
|
82
82
|
metrics/formats.py turns each request into OpenAI messages and the answer
|
|
83
|
-
back into the format's reply. A request carrying tools
|
|
84
|
-
|
|
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.
|
|
85
98
|
|
|
86
99
|
Asked to stream, the reply's 200 goes as soon as the panel is seated (a
|
|
87
100
|
refusal before that keeps its status), keep-alives go while the seats
|
|
@@ -91,8 +104,9 @@ format's own shape (Handler._stream).
|
|
|
91
104
|
|
|
92
105
|
WHAT IT IS NOT
|
|
93
106
|
--------------
|
|
94
|
-
A
|
|
95
|
-
|
|
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
|
|
96
110
|
as offloads always were, or the built-in client (BENCH_CLIENT), so endpoints,
|
|
97
111
|
headers and per-model options all come from the same registry. Full prompts
|
|
98
112
|
and answers go to `runs/gateway/`, gitignored, with the name of the gateway
|
|
@@ -105,10 +119,12 @@ CORS). The dashboard answers these same routes on its own port.
|
|
|
105
119
|
from __future__ import annotations
|
|
106
120
|
|
|
107
121
|
import datetime as dt
|
|
122
|
+
import hashlib
|
|
108
123
|
import json
|
|
109
124
|
import os
|
|
110
125
|
import pathlib
|
|
111
126
|
import queue
|
|
127
|
+
import random
|
|
112
128
|
import re
|
|
113
129
|
import secrets
|
|
114
130
|
import subprocess
|
|
@@ -650,16 +666,18 @@ def models() -> list[str]:
|
|
|
650
666
|
|
|
651
667
|
def listed(who: str) -> list[tuple[str, dict]]:
|
|
652
668
|
"""(model name, what it is for) for every collection key `who` may call,
|
|
653
|
-
each
|
|
654
|
-
only listed models
|
|
655
|
-
|
|
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."""
|
|
656
673
|
s, rows = bench_collections.settings(), []
|
|
657
674
|
for n, c in bench_collections.load().items():
|
|
658
675
|
if not guard.may_call(who, n): # a key limited to some collections sees those
|
|
659
676
|
continue
|
|
660
677
|
d = bench_collections.described(n, c, s)
|
|
661
|
-
|
|
662
|
-
rows += [(
|
|
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"]]
|
|
663
681
|
return rows
|
|
664
682
|
|
|
665
683
|
|
|
@@ -683,6 +701,260 @@ def refusal(exc: Exception) -> tuple[int, str]:
|
|
|
683
701
|
return 500, f"the panel failed ({type(exc).__name__}: {exc})"
|
|
684
702
|
|
|
685
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
|
+
|
|
686
958
|
class Handler(BaseHTTPRequestHandler):
|
|
687
959
|
"""The gateway's routes. serve.py's Handler subclasses this to answer them
|
|
688
960
|
on the dashboard's port too, so the checks use the port actually served."""
|
|
@@ -717,7 +989,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
717
989
|
if headers is not None and guard.gateway_route((getattr(self, "path", "") or "").split("?", 1)[0]) \
|
|
718
990
|
and (origin := guard.cors_origin(headers, self.server.server_port)):
|
|
719
991
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
720
|
-
self.send_header("Access-Control-Expose-Headers",
|
|
992
|
+
self.send_header("Access-Control-Expose-Headers",
|
|
993
|
+
"X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model")
|
|
721
994
|
self.send_header("Vary", "Origin")
|
|
722
995
|
super().end_headers()
|
|
723
996
|
|
|
@@ -856,6 +1129,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
856
1129
|
# Chat completions have always answered with the collection's name;
|
|
857
1130
|
# the other formats echo the name as asked, which their clients match.
|
|
858
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)
|
|
859
1136
|
if ask["stream"]:
|
|
860
1137
|
out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
|
|
861
1138
|
return self._stream(fmt, name, ask, reply, out)
|
|
@@ -917,6 +1194,71 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
917
1194
|
except OSError:
|
|
918
1195
|
return # the caller left; the panel still finishes, every seat recorded
|
|
919
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
|
|
1261
|
+
|
|
920
1262
|
def log_request(self, code="-", size="-"): # route, status, who; never a query or a key
|
|
921
1263
|
guard.access(self, code)
|
|
922
1264
|
|
|
@@ -15,8 +15,11 @@ the same collection is called every day:
|
|
|
15
15
|
seats; empty means a decision model labels each prompt, or `general` without
|
|
16
16
|
one. `reply` is what the caller reads: `all` (every answer, the default) or
|
|
17
17
|
`merged` (one answer a `merger` model writes from them; empty means chosen by
|
|
18
|
-
verdict record, gateway.py)
|
|
19
|
-
|
|
18
|
+
verdict record, gateway.py) or `route` (no panel: one member, picked by its
|
|
19
|
+
record, gets the request as sent, tools and all, for agent clients;
|
|
20
|
+
GATEWAY.md "Route mode"). A request may override it with a `:merged`,
|
|
21
|
+
`:all` or `:route` suffix on the model name, or `"bench": {"reply": ...}`
|
|
22
|
+
in the body.
|
|
20
23
|
Named `bench_collections` because `collections` would shadow the standard
|
|
21
24
|
library for everything importing from metrics/.
|
|
22
25
|
"""
|
|
@@ -34,7 +37,7 @@ SETTINGS_FILE = DATA / "config" / "gateway.json"
|
|
|
34
37
|
DEFAULTS = {"max_calls": 5, "max_explorers": 2}
|
|
35
38
|
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
|
|
36
39
|
PREFIX = "bench/"
|
|
37
|
-
REPLIES = ("all", "merged")
|
|
40
|
+
REPLIES = ("all", "merged", "route")
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
def _read(path: pathlib.Path) -> dict:
|
|
@@ -135,6 +138,7 @@ if __name__ == "__main__":
|
|
|
135
138
|
assert explorers_for(3, {"max_calls": 4, "max_explorers": 2}) == 1
|
|
136
139
|
assert resolve("bench/") is None and resolve("bench/no-such") is None and resolve("") is None
|
|
137
140
|
assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
|
|
141
|
+
assert split_reply("bench/core:route") == ("bench/core", "route")
|
|
138
142
|
assert split_reply("bench/core") == ("bench/core", "") and split_reply("bench/core:x") == ("bench/core:x", "")
|
|
139
143
|
assert not reply_errors("merged", "", []) and not reply_errors("all", "m", ["m"])
|
|
140
144
|
assert len(reply_errors("", "m", [])) == 2
|