@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/AGENTS.md +6 -1
- package/GATEWAY.md +149 -6
- package/README.md +42 -2
- package/USAGE.md +16 -0
- package/VERSION +1 -1
- package/cli.py +9 -1
- package/gateway.py +275 -91
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +537 -127
- package/metrics/cost.py +38 -19
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +5 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +549 -0
- package/scripts/test_merged.py +4 -3
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +104 -36
- package/ui/bench.js +3 -2
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
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
self._error(404, "GET /v1/models is the only GET here")
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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:
|
package/metrics/catalog.py
CHANGED
|
@@ -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:
|