@wendellresearch-ai/benchwarmers 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 is refused (400): a
84
- panel's text is not a tool call.
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 single-model proxy. Tools such as Roo Code call Open WebUI directly; this
95
- exists only for the multi-model work. Seats are called with `llm -m`, exactly
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 twice: as set, and in the other reply mode, so a client that offers
654
- only listed models can still pick either. What GET /v1/models, /api/tags
655
- and /v1beta/models list, each in its own shape."""
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
- other = "all" if d["reply"] == "merged" else "merged"
662
- rows += [(bench_collections.PREFIX + n, d), (f"{bench_collections.PREFIX}{n}:{other}", {**d, "reply": other})]
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", "X-Bench-Panel, X-Bench-Seats")
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). A request may override it with a `:merged` or
19
- `:all` suffix on the model name, or `"bench": {"reply": ...}` in the body.
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