@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/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 single-model proxy. Tools such as Roo Code call Open WebUI directly; this
79
- 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
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 int(len(text) / 4 * 1.1)
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
- for (key, label, alias), r in zip(seats, results):
538
- call = f"{panel_id}.{key}"
539
- record["seats"].append(record_seat(task, call, label, alias, r))
540
- body = r["answer"] or (f"_error: {r['error'][:300]}_" if r["error"] else
541
- "_empty reply — usually a token budget, not incapacity_")
542
- out.append(f"## {label} — {alias}\n`{call}` · {r['status']} · {r['seconds']}s\n\n{body}\n")
543
- out.append("---\nRecord a verdict per seat once checked against the code:\n"
544
- "`POST /bench/verdict` on this same server (see `bench.verdict`), or"
545
- " `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
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
- self._json(code, {"error": {"message": message, "type": "bench_gateway", "code": code}}, headers=headers)
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 self.path.split("?", 1)[0].rstrip("/") in ("/v1/models", "/models"):
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
- # Each collection twice: as set, and in the other reply mode, so a
642
- # client that offers only listed models can still pick either.
643
- s, data = bench_collections.settings(), bench_collections.load()
644
- rows = []
645
- for n, c in data.items():
646
- if not guard.may_call(self.who, n): # a key limited to some collections sees those
647
- continue
648
- d = bench_collections.described(n, c, s)
649
- other = "all" if d["reply"] == "merged" else "merged"
650
- rows += [{"id": bench_collections.PREFIX + n, "object": "model", "owned_by": "benchwarmers", **d},
651
- {"id": f"{bench_collections.PREFIX}{n}:{other}", "object": "model",
652
- "owned_by": "benchwarmers", **d, "reply": other}]
653
- self._json(200, {"object": "list", "data": rows})
654
- else:
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
- if path not in ("/v1/chat/completions", "/chat/completions"):
685
- return self._error(404, "POST /v1/chat/completions or /bench/verdict")
686
- model = body.get("model") or ""
687
- if not isinstance(model, str):
688
- return self._error(400, '"model" must be a collection name such as bench/core')
689
- model, suffix = bench_collections.split_reply(model)
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
- # The body is the most specific, then the suffix, then the collection.
710
- content, bench = run_collection(name, messages, nominated, opts.get("reply") or suffix, self.who)
711
- except TooLarge as exc:
712
- return self._error(413, str(exc))
713
- except ValueError as exc:
714
- return self._error(400, str(exc))
715
- except LookupError as exc:
716
- return self._error(503, str(exc))
717
- reply_id, created = f"chatcmpl-{secrets.token_hex(8)}", int(time.time())
718
- if not body.get("stream"):
719
- return self._json(200, {
720
- "id": reply_id, "object": "chat.completion", "created": created, "model": model,
721
- "choices": [{"index": 0, "finish_reason": "stop",
722
- "message": {"role": "assistant", "content": content}}], "bench": bench})
723
- # A panel finishes all at once, so a streaming client gets it as one
724
- # chunk, merged or not. ponytail: nothing is sent until the panel and
725
- # the merge are done, so a client or proxy with a shorter idle timeout
726
- # drops it. Keep-alives mean sending the 200 before the panel runs, so
727
- # the 413/503 refusals move into the stream; streaming the merge as it
728
- # is written needs a streaming client.chat (RELEASE-PLAN acceptance 11).
729
- self.send_response(200)
730
- self.send_header("Content-Type", "text/event-stream")
731
- self.end_headers()
732
- for delta, finish in (({"role": "assistant", "content": content}, None), ({}, "stop")):
733
- chunk = {"id": reply_id, "object": "chat.completion.chunk", "created": created,
734
- "model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
735
- **({"bench": bench} if finish is None else {})}
736
- self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
737
- self.wfile.write(b"data: [DONE]\n\n")
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
- sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
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: