@wendellresearch-ai/benchwarmers 0.2.1 → 0.2.2

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,6 +73,22 @@ 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 request carrying tools is refused (400): a
84
+ panel's text is not a tool call.
85
+
86
+ Asked to stream, the reply's 200 goes as soon as the panel is seated (a
87
+ refusal before that keeps its status), keep-alives go while the seats
88
+ think, each seat's section goes as that seat finishes (a merged reply when
89
+ the merge is done), and an error after the 200 goes into the stream in the
90
+ format's own shape (Handler._stream).
91
+
76
92
  WHAT IT IS NOT
77
93
  --------------
78
94
  A single-model proxy. Tools such as Roo Code call Open WebUI directly; this
@@ -92,13 +108,15 @@ import datetime as dt
92
108
  import json
93
109
  import os
94
110
  import pathlib
111
+ import queue
95
112
  import re
96
113
  import secrets
97
114
  import subprocess
98
115
  import sys
116
+ import threading
99
117
  import time
100
118
  import urllib.parse
101
- from concurrent.futures import ThreadPoolExecutor
119
+ from concurrent.futures import ThreadPoolExecutor, as_completed
102
120
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
103
121
 
104
122
  ROOT = pathlib.Path(__file__).resolve().parent
@@ -114,7 +132,9 @@ sys.path.insert(0, str(ROOT / "suite"))
114
132
  import bench_collections # noqa: E402
115
133
  import client # noqa: E402
116
134
  import decide # noqa: E402
135
+ import formats # noqa: E402
117
136
  import guard # noqa: E402
137
+ import model_roles # noqa: E402
118
138
  import offload # noqa: E402
119
139
  import route # noqa: E402
120
140
 
@@ -145,6 +165,13 @@ URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
145
165
  BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
146
166
  HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
147
167
  SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
168
+ QUERY_KEY = re.compile(r"([?&](?:key|api_key|access_token)=)[^&\s'\"]*", re.I)
169
+ # A streaming reply: how long a request may take to be seated before its 200
170
+ # goes anyway (a refusal before then keeps its status), and how often a
171
+ # keep-alive goes while the seats think. Idle timeouts in clients and proxies
172
+ # start around 30 seconds.
173
+ STREAM_GRACE = 1.0
174
+ KEEPALIVE = 10.0
148
175
  # A merge that reads as consensus hides the dissent the bench runs on, so the
149
176
  # merger is told to keep who said what.
150
177
  MERGE_SYSTEM = ("Several models answered the same request. Write one answer to it from theirs: say where"
@@ -370,7 +397,18 @@ def estimate_tokens(text: str) -> int:
370
397
  size, which errs high too: OpenAI's, Anthropic's and Gemini's models spend
371
398
  up to about 1,600 on one at their usual detail. Qwen and Llama vision
372
399
  models can spend more on a very large image; a learned error corrects that."""
373
- return int(len(text) / 4 * 1.1)
400
+ return formats.estimate(text)
401
+
402
+
403
+ def prompt_tokens(messages: list[dict]) -> int:
404
+ """The estimate for a whole request, images counted: what a reply's usage
405
+ and a count_tokens route say."""
406
+ system, prompt = flatten(messages)
407
+ try:
408
+ pictures = len(images(messages))
409
+ except ValueError: # refused by run_collection, which says why
410
+ pictures = 0
411
+ return estimate_tokens(f"{system}\n\n{prompt}".strip()) + IMAGE_TOKENS * pictures
374
412
 
375
413
 
376
414
  def too_small(needed: int, aliases) -> dict[str, int]:
@@ -440,11 +478,25 @@ def merge(collection: dict, members: list[str], shape: str, whole: str, seats: l
440
478
  return seat, ""
441
479
 
442
480
 
481
+ def section(seat: dict) -> str:
482
+ """One seat's part of an every-answer reply: its heading, call id, status and answer."""
483
+ body = seat["answer"] or (f"_error: {seat['error'][:300]}_" if seat["error"] else
484
+ "_empty reply — usually a token budget, not incapacity_")
485
+ return f"## {seat['seat']} — {seat['alias']}\n`{seat['call']}` · {seat['status']} · {seat['seconds']}s\n\n{body}\n"
486
+
487
+
443
488
  def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
444
- reply: str = "", who: str = "local") -> tuple[str, dict]:
489
+ reply: str = "", who: str = "local", on_part=None) -> tuple[str, dict]:
445
490
  """The panel's reply as markdown, and the same seats as data for the
446
491
  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."""
492
+ collection's own. `who`: the gateway key that asked, for the call log.
493
+
494
+ `on_part`, for a streaming caller: called with each piece of an
495
+ every-answer reply as it is ready, the heading once the panel is seated,
496
+ each seat's section as that seat finishes, then the closing lines. The
497
+ pieces add up to the content, with the seats in the order they finished
498
+ rather than seat order. Not called for a merged reply: the merge is the
499
+ reply, and it comes at the end (or every answer, if it fails)."""
448
500
  pictures = images(messages)
449
501
  system, prompt = flatten(messages)
450
502
  if not prompt.strip():
@@ -477,6 +529,13 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
477
529
  # answer anyway, about a picture it never got. Skipped, and named.
478
530
  skipped += [{"seat": seat, "alias": a, "why": why} for seat, group in (("member", members), ("with", nominated))
479
531
  for a in group if pictures and (why := cannot_see(a))]
532
+ # A VISION SPECIALIST SITS ONLY FOR PICTURES: its role says it reads
533
+ # images, not that it answers text, so on a text request its seat goes to
534
+ # the next member or an explorer. Named in `with`, it sits anyway.
535
+ if not pictures:
536
+ roles = model_roles.load()["models"]
537
+ skipped += [{"seat": "member", "alias": a, "why": "a vision specialist: sits only when the request"
538
+ " carries images"} for a in members if roles.get(a) == "vision"]
480
539
  unseen = {k["alias"] for k in skipped}
481
540
  sitting = [a for a in members if a not in unseen]
482
541
  nominated = [a for a in nominated if a not in unseen]
@@ -522,27 +581,39 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
522
581
  + "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
523
582
  panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
524
583
  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
584
  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
585
  out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
534
586
  f" explorers · explorer shape {shape} ({how})\n"]
535
587
  if skipped:
536
588
  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 \"...\"`")
589
+ emit = None if merged else on_part
590
+ if emit:
591
+ emit("\n".join(out))
592
+ done: dict[int, dict] = {}
593
+ with ThreadPoolExecutor(max_workers=len(seats)) as pool:
594
+ asked = {pool.submit(ask_seat, alias, system, prompt, messages if pictures else None): i
595
+ for i, (_key, _label, alias) in enumerate(seats)}
596
+ # Each seat is logged the moment it answers, so explorer rotation
597
+ # counts it even if a slower seat (or the caller) never finishes.
598
+ for future in as_completed(asked):
599
+ i = asked[future]
600
+ key, label, alias = seats[i]
601
+ done[i] = record_seat(task, f"{panel_id}.{key}", label, alias, future.result())
602
+ if emit:
603
+ emit("\n" + section(done[i]))
604
+
605
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
606
+ record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
607
+ "model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
608
+ "prompt": prompt, "seats": [done[i] for i in range(len(seats))],
609
+ **({"skipped": skipped} if skipped else {})}
610
+ out += [section(s) for s in record["seats"]]
611
+ footer = ("---\nRecord a verdict per seat once checked against the code:\n"
612
+ "`POST /bench/verdict` on this same server (see `bench.verdict`), or"
613
+ " `python metrics/offload.py verdict <call id> --useful|--not-useful --note \"...\"`")
614
+ out.append(footer)
615
+ if emit:
616
+ emit("\n" + footer)
546
617
  content = "\n".join(out)
547
618
  # Beyond the OpenAI fields, so a caller reads seats without parsing the
548
619
  # markdown. Clients that do not know it ignore it.
@@ -577,6 +648,41 @@ def models() -> list[str]:
577
648
  return [bench_collections.PREFIX + n for n in bench_collections.load()]
578
649
 
579
650
 
651
+ def listed(who: str) -> list[tuple[str, dict]]:
652
+ """(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."""
656
+ s, rows = bench_collections.settings(), []
657
+ for n, c in bench_collections.load().items():
658
+ if not guard.may_call(who, n): # a key limited to some collections sees those
659
+ continue
660
+ 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})]
663
+ return rows
664
+
665
+
666
+ def bench_headers(bench: dict) -> dict:
667
+ """The panel's call ids as headers, for a format with no room for the
668
+ bench field in its body (Gemini): X-Bench-Panel, and X-Bench-Seats as
669
+ JSON, without the answers (they are in the reply)."""
670
+ seats = bench.get("seats", []) + ([bench["merger"]] if bench.get("merger") else [])
671
+ return {"X-Bench-Panel": str(bench.get("panel", "")),
672
+ "X-Bench-Seats": json.dumps([{k: s.get(k) for k in ("call", "seat", "alias", "status")} for s in seats])}
673
+
674
+
675
+ def refusal(exc: Exception) -> tuple[int, str]:
676
+ """The status and message for a panel that could not run."""
677
+ if isinstance(exc, TooLarge):
678
+ return 413, str(exc)
679
+ if isinstance(exc, ValueError):
680
+ return 400, str(exc)
681
+ if isinstance(exc, LookupError):
682
+ return 503, str(exc)
683
+ return 500, f"the panel failed ({type(exc).__name__}: {exc})"
684
+
685
+
580
686
  class Handler(BaseHTTPRequestHandler):
581
687
  """The gateway's routes. serve.py's Handler subclasses this to answer them
582
688
  on the dashboard's port too, so the checks use the port actually served."""
@@ -592,8 +698,16 @@ class Handler(BaseHTTPRequestHandler):
592
698
  self.end_headers()
593
699
  self.wfile.write(body)
594
700
 
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)
701
+ def _error(self, code: int, message: str, headers: dict | None = None, fmt: str = "chat") -> None:
702
+ """A refusal, in the shape the route's clients read (metrics/formats.py)."""
703
+ self._json(code, formats.INBOUND[fmt].error(code, message), headers=headers)
704
+
705
+ def _query_key(self, fmt: str) -> str | None:
706
+ """A Gemini route's ?key=, as Google's REST examples send it; no other
707
+ route takes a key in its URL, where proxies and histories keep it."""
708
+ if fmt != "gemini":
709
+ return None
710
+ return urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("key", [None])[0]
597
711
 
598
712
  def end_headers(self):
599
713
  # CORS for a browser app on an allowed origin, on the gateway's routes
@@ -603,6 +717,7 @@ class Handler(BaseHTTPRequestHandler):
603
717
  if headers is not None and guard.gateway_route((getattr(self, "path", "") or "").split("?", 1)[0]) \
604
718
  and (origin := guard.cors_origin(headers, self.server.server_port)):
605
719
  self.send_header("Access-Control-Allow-Origin", origin)
720
+ self.send_header("Access-Control-Expose-Headers", "X-Bench-Panel, X-Bench-Seats")
606
721
  self.send_header("Vary", "Origin")
607
722
  super().end_headers()
608
723
 
@@ -630,45 +745,47 @@ class Handler(BaseHTTPRequestHandler):
630
745
  self.end_headers()
631
746
 
632
747
  def do_GET(self): # noqa: N802
748
+ path = self.path.split("?", 1)[0].rstrip("/")
749
+ fmt = formats.format_of(path)
633
750
  try:
634
751
  guard.check(self.headers, self.server.server_port, post=False, max_body=0)
635
- self.who = guard.gateway_caller(self.headers)
752
+ self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
636
753
  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"):
754
+ return self._error(r.status, str(r), r.headers, fmt)
755
+ if path in ("/v1/models", "/models"):
639
756
  # Beyond the OpenAI fields: what each collection is for, so an
640
757
  # 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")
758
+ return self._json(200, {"object": "list", "data": [
759
+ {"id": n, "object": "model", "owned_by": "benchwarmers", **d} for n, d in listed(self.who)]})
760
+ if path == "/api/tags":
761
+ return self._json(200, formats.ollama_tags(listed(self.who)))
762
+ if path == "/api/version":
763
+ return self._json(200, {"version": formats.OLLAMA_VERSION})
764
+ if path == "/v1beta/models":
765
+ return self._json(200, formats.gemini_models(listed(self.who)))
766
+ if path.startswith("/v1beta/models/"):
767
+ name = urllib.parse.unquote(path[len("/v1beta/models/"):])
768
+ row = dict(listed(self.who)).get(name)
769
+ return self._json(200, formats.gemini_model(name, row)) if row else \
770
+ self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
771
+ self._error(404, "GET /v1/models, /api/tags, /api/version or /v1beta/models", fmt=fmt)
656
772
 
657
773
  def do_POST(self): # noqa: N802
658
774
  # Without this any web page could spend model calls, seat ad hoc
659
775
  # models through `with`, or write verdicts (metrics/guard.py).
776
+ path = self.path.split("?", 1)[0].rstrip("/")
777
+ fmt = formats.format_of(path)
660
778
  try:
661
779
  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)
780
+ self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
663
781
  except guard.Refused as r:
664
- return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers)
782
+ return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers, fmt)
665
783
  try:
666
784
  body = json.loads(self.rfile.read(length) or b"{}")
667
785
  except ValueError:
668
- return self._error(400, "body is not JSON")
786
+ return self._error(400, "body is not JSON", fmt=fmt)
669
787
  if not isinstance(body, dict):
670
- return self._error(400, "body must be a JSON object")
671
- path = self.path.split("?", 1)[0].rstrip("/")
788
+ return self._error(400, "body must be a JSON object", fmt=fmt)
672
789
  if path == "/bench/verdict":
673
790
  if not isinstance(body.get("useful"), bool):
674
791
  return self._error(400, '"useful" must be true or false')
@@ -681,66 +798,133 @@ class Handler(BaseHTTPRequestHandler):
681
798
  except KeyError as exc:
682
799
  return self._error(404, exc.args[0])
683
800
  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)
801
+ # Each format's request becomes one Ask (metrics/formats.py); a part
802
+ # that would be lost on the way, tools above all, is refused with why.
803
+ gemini = formats.gemini_route(path)
804
+ try:
805
+ if gemini and gemini[1] == "countTokens":
806
+ ask = formats.gemini_ask(body.get("generateContentRequest") or body, gemini[0])
807
+ return self._json(200, {"totalTokens": prompt_tokens(ask["messages"])})
808
+ if gemini:
809
+ if gemini[1] not in ("generateContent", "streamGenerateContent"):
810
+ return self._error(404, f"POST models/<collection>:generateContent or :streamGenerateContent,"
811
+ f" not :{gemini[1]}", fmt=fmt)
812
+ ask = formats.gemini_ask(body, gemini[0], gemini[1] == "streamGenerateContent")
813
+ elif path == "/v1/messages/count_tokens":
814
+ # An estimate, as the size check makes, without calling a model.
815
+ return self._json(200, {"input_tokens": prompt_tokens(formats.anthropic_ask(body)["messages"])})
816
+ elif path == "/api/show":
817
+ return self._show(body)
818
+ elif path in formats.POST_ROUTES:
819
+ ask = formats.INBOUND[fmt].ask(body)
820
+ else:
821
+ return self._error(404, "POST /v1/chat/completions, /v1/messages, /v1/responses, /v1/completions,"
822
+ " /api/chat, /api/generate, /v1beta/models/<collection>:generateContent"
823
+ " or /bench/verdict", fmt=fmt)
824
+ except formats.Unsupported as exc:
825
+ return self._error(400, str(exc), fmt=fmt)
826
+ alt = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("alt", [""])[0]
827
+ self._panel(fmt, ask, sse=alt == "sse")
828
+
829
+ def _show(self, body: dict) -> None:
830
+ """POST /api/show: Ollama's model card, for a collection the key may call."""
831
+ name = formats.ollama_model(body.get("model") or body.get("name") or "")
832
+ rows = dict(listed(self.who))
833
+ row = rows.get(name) or rows.get(bench_collections.PREFIX + str(name))
834
+ if not row:
835
+ return self._error(404, f"model {name!r} not found; GET /api/tags lists the collections",
836
+ fmt="ollama_chat")
837
+ self._json(200, formats.ollama_show(name, row))
838
+
839
+ def _panel(self, fmt: str, ask: dict, sse: bool = True) -> None:
840
+ """One panel, for a request in any format: the collection it names,
841
+ the key's scope and cap, then the panel, answered in that format."""
842
+ model, suffix = bench_collections.split_reply(ask["model"])
690
843
  name = bench_collections.resolve(model)
691
844
  if name is None:
692
- return self._error(400 if not model else 404, bench_collections.choose_message(model))
845
+ return self._error(400 if not model else 404, bench_collections.choose_message(model), fmt=fmt)
693
846
  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
847
  if not guard.may_call(self.who, name):
702
848
  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}")
849
+ return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}",
850
+ fmt=fmt)
704
851
  try:
705
852
  guard.spend(self.who) # counted once it names a collection it may call
706
853
  except guard.Refused as r:
707
- return self._error(r.status, str(r), r.headers)
854
+ return self._error(r.status, str(r), r.headers, fmt)
855
+ reply = ask["reply"] or suffix # the body is the most specific, then the suffix, then the collection
856
+ # Chat completions have always answered with the collection's name;
857
+ # the other formats echo the name as asked, which their clients match.
858
+ shown = model if fmt == "chat" else ask["model"]
859
+ if ask["stream"]:
860
+ out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
861
+ return self._stream(fmt, name, ask, reply, out)
862
+ started = time.monotonic()
708
863
  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")
864
+ content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who)
865
+ except (ValueError, LookupError) as exc: # TooLarge is a ValueError
866
+ return self._error(*refusal(exc), fmt=fmt)
867
+ usage = (prompt_tokens(ask["messages"]), estimate_tokens(content))
868
+ self._json(200, formats.INBOUND[fmt].reply(content, shown, bench, usage, time.monotonic() - started),
869
+ bench_headers(bench) if fmt == "gemini" else None)
870
+
871
+ def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream) -> None:
872
+ """The panel as it comes, in the format's stream shape: every answer
873
+ seat by seat as each finishes, or a merged reply when the merge is
874
+ done. The panel runs in its own thread and this one writes, so a
875
+ keep-alive goes out every KEEPALIVE seconds while the seats think. A
876
+ refusal raised before anything is ready (the size check, an image no
877
+ seat can see: milliseconds) still gets its status; the 200 goes once
878
+ the panel is seated or after STREAM_GRACE, whichever is first, and
879
+ from then on an error goes into the stream in the format's shape."""
880
+ events: queue.Queue = queue.Queue()
881
+
882
+ def work():
883
+ try:
884
+ events.put(("done", run_collection(name, ask["messages"], ask["with"], reply, self.who,
885
+ on_part=lambda text: events.put(("part", text)))))
886
+ except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
887
+ events.put(("error", exc))
888
+ threading.Thread(target=work, daemon=True).start()
889
+ started = streamed = False
890
+ while True:
891
+ try:
892
+ kind, data = events.get(timeout=KEEPALIVE if started else STREAM_GRACE)
893
+ except queue.Empty:
894
+ kind, data = "idle", None
895
+ if kind == "error" and not started:
896
+ return self._error(*refusal(data), fmt=fmt)
897
+ try:
898
+ if not started:
899
+ self.send_response(200)
900
+ self.send_header("Content-Type", out.content_type)
901
+ self.send_header("Cache-Control", "no-cache")
902
+ self.end_headers()
903
+ self.wfile.write(out.start())
904
+ started = True
905
+ if kind == "idle":
906
+ self.wfile.write(out.keepalive())
907
+ elif kind == "part":
908
+ self.wfile.write(out.text(data))
909
+ streamed = True
910
+ elif kind == "done":
911
+ content, bench = data
912
+ self.wfile.write((b"" if streamed else out.text(content)) + out.end(bench))
913
+ return
914
+ else:
915
+ self.wfile.write(out.error(*refusal(data)))
916
+ return
917
+ except OSError:
918
+ return # the caller left; the panel still finishes, every seat recorded
738
919
 
739
920
  def log_request(self, code="-", size="-"): # route, status, who; never a query or a key
740
921
  guard.access(self, code)
741
922
 
742
923
  def log_message(self, fmt, *args): # quieter than the default
743
- sys.stderr.write(f"{self.log_date_time_string()} {fmt % args}\n")
924
+ # http.server's own errors quote the request line, ?key= and all
925
+ # ("Bad request syntax ('GET /v1beta/models?key=...')").
926
+ line = QUERY_KEY.sub(r"\1...", fmt % args)
927
+ sys.stderr.write(f"{self.log_date_time_string()} {line}\n")
744
928
 
745
929
 
746
930
  def main() -> None:
@@ -11,7 +11,11 @@ THREE FILES, ONE RECORD
11
11
  config/model-overrides.json WRITTEN BY YOU (the model popup). Per alias,
12
12
  any field, including its own rate limits.
13
13
  config/connection-limits.json WRITTEN BY YOU (the Endpoints dialog). Per
14
- connection: rpm, rpd, tpm, tpd.
14
+ connection: rpm, rpd, tpm, tpd, and rpmo
15
+ (calls a calendar month).
16
+
17
+ Since Phase 2b the built-in client enforces these, a model's own limits and
18
+ the learned ones (metrics/pacing.py); before, they were only shown.
15
19
 
16
20
  `info(alias)` merges them: an override beats the refresh, a model's own limits
17
21
  beat its connection's. Rate limits are kept per CONNECTION (nvidia-api, groq,
@@ -52,6 +56,11 @@ FIELDS = ("context_window", "max_output", "vision", "tools", "structured_output"
52
56
  "release_date", "open_weights", "description")
53
57
  CAPS = ("tools", "vision", "structured_output", "reasoning")
54
58
  LIMIT_KEYS = ("rpm", "rpd", "tpm", "tpd")
59
+ # A connection's limits add a monthly cap: calls a calendar month (UTC), as a
60
+ # trial key has (Cohere's: 1,000). Per key, so not among a model's own limits.
61
+ CONNECTION_LIMIT_KEYS = LIMIT_KEYS + ("rpmo",)
62
+ LIMIT_LABELS = {"rpm": "requests per minute", "rpd": "requests per day", "tpm": "tokens per minute",
63
+ "tpd": "tokens per day", "rpmo": "requests per calendar month (UTC)"}
55
64
 
56
65
 
57
66
  def _read(path: pathlib.Path) -> dict: