@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/AGENTS.md +6 -1
- package/GATEWAY.md +306 -6
- package/README.md +53 -3
- package/USAGE.md +31 -0
- package/VERSION +1 -1
- package/cli.py +10 -2
- package/gateway.py +619 -93
- package/metrics/bench_collections.py +7 -3
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +764 -131
- package/metrics/cost.py +38 -19
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +1584 -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 +7 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_auth.py +2 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +558 -0
- package/scripts/test_merged.py +6 -5
- package/scripts/test_native.py +188 -0
- package/scripts/test_quota.py +334 -0
- package/scripts/test_route.py +429 -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 +170 -44
- package/ui/bench.js +6 -3
package/metrics/client.py
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
python metrics/client.py <alias> "prompt" # one call; prints what ask_once would return
|
|
4
4
|
|
|
5
|
-
RELEASE-PLAN.md
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
parallel seats hit in llm's log.
|
|
5
|
+
RELEASE-PLAN.md Phases 2a and 2b. A drop-in for `llm -m <alias>`, chosen on
|
|
6
|
+
Settings or by BENCH_CLIENT=builtin (see chosen()), used by suite/run.py's
|
|
7
|
+
ask_once, and so the perf probe, the suite, retries and auto-retire's
|
|
8
|
+
confirmation call, and by the gateway's seats. It reads the same
|
|
9
|
+
registration (the yaml entry's model name, base, key name and headers), the
|
|
10
|
+
same keys.json and the same per-model options as `llm`, and it ends the
|
|
11
|
+
"database is locked" crash that parallel seats hit in llm's log.
|
|
13
12
|
|
|
14
13
|
THE ERROR TEXT IS A CONTRACT
|
|
15
14
|
----------------------------
|
|
@@ -26,37 +25,93 @@ markers llm's produced:
|
|
|
26
25
|
reads `Error: client failed: ...`, the counterpart of ask_once's
|
|
27
26
|
`Error: llm exited N: ...`; both are in suite/run.py ERRORS. A bare
|
|
28
27
|
"Error: Connection error." would have scored as an answer in the perf tier,
|
|
29
|
-
which accepts one-word replies.
|
|
28
|
+
which accepts one-word replies. A call the bench declines to send says so
|
|
29
|
+
in the same form: pacing's `Error: client failed: rate limit (not sent;
|
|
30
|
+
...)` is a capacity failure (metrics/pacing.py), the free-tier guard's
|
|
31
|
+
`... payment required ...` an entitlement one.
|
|
30
32
|
- no answer before the deadline is `<<TIMED OUT after Ns>>`;
|
|
31
33
|
- an empty answer is the empty string (a reasoning model out of budget), but
|
|
32
34
|
a 200 with no choice at all is `client failed`: a broken endpoint, which
|
|
33
|
-
"empty" would have kept out of auto-retirement.
|
|
35
|
+
"empty" would have kept out of auto-retirement. So is a stream that breaks
|
|
36
|
+
off before it says it is finished: a cut answer graded as the model's work
|
|
37
|
+
is the confusion this repo exists to prevent.
|
|
34
38
|
|
|
35
39
|
Redirects are not followed (see _NoRedirect): urllib would hand the key to
|
|
36
40
|
whatever host a redirect names.
|
|
37
41
|
|
|
42
|
+
STREAMED BY DEFAULT (2b)
|
|
43
|
+
------------------------
|
|
44
|
+
Chat completions are asked for as a stream (`stream: true`, with
|
|
45
|
+
`stream_options.include_usage` for the token counts), as llm asks: a 5-7
|
|
46
|
+
minute reasoning answer survives a proxy's idle timeout only if bytes keep
|
|
47
|
+
coming. An upstream that answers with plain JSON anyway is read as before,
|
|
48
|
+
and one that refuses the stream options (a 400 or 422 naming "stream") is
|
|
49
|
+
asked once more without them. Reasoning text (`reasoning_content`, or
|
|
50
|
+
`reasoning`) is kept apart from the answer. `on_delta(kind, text)`, when
|
|
51
|
+
given, hears each piece as it arrives (kind "text" or "reasoning") so a
|
|
52
|
+
caller can relay it; a callback that raises is dropped, never the call. The
|
|
53
|
+
deadline is checked between chunks, and so is STOP, which a server shutting
|
|
54
|
+
down sets.
|
|
55
|
+
|
|
56
|
+
WHAT EVERY CALL LEAVES BEHIND (2b)
|
|
57
|
+
----------------------------------
|
|
58
|
+
- a ledger line (metrics/ledger.py): tokens in and out, seconds, status,
|
|
59
|
+
attempts, caller;
|
|
60
|
+
- quota records from the reply's rate-limit headers and any 429 body
|
|
61
|
+
(metrics/quota.py);
|
|
62
|
+
- a silent-swap note when the reply names another model than the one
|
|
63
|
+
registered (learn.note_served_as, and `swap` in the ledger line); the call
|
|
64
|
+
still succeeds.
|
|
65
|
+
|
|
66
|
+
BEFORE A CALL IS SENT (2b)
|
|
67
|
+
--------------------------
|
|
68
|
+
- the free-tier guard: a model on an active "went paid" alert (catalog.py
|
|
69
|
+
saw a price) is not called, so a free model turned paid never bills by
|
|
70
|
+
surprise. Allowed per model by config/connections.json `allow_paid`
|
|
71
|
+
(aliases), or for one process by BENCH_ALLOW_PAID=1. A connection paid by
|
|
72
|
+
subscription never raises that alert in the first place.
|
|
73
|
+
- pacing (metrics/pacing.py): stored and learned limits, the monthly cap and
|
|
74
|
+
quota records, counted from the ledger with calls in flight.
|
|
75
|
+
|
|
76
|
+
AN EMPTY ANSWER CUT OFF BY ITS BUDGET IS TRIED ONCE MORE: when the reply is
|
|
77
|
+
empty and finished for "length", the same call goes again with twice the
|
|
78
|
+
output budget (capped at the model's known max output), and the result says
|
|
79
|
+
so (`budget_retry`).
|
|
80
|
+
|
|
38
81
|
ENDPOINTS CONNECTED BY SIGN-IN (metrics/connect.py) come through here even
|
|
39
82
|
when BENCH_CLIENT is `llm` (see backend()): a token that renews is renewed
|
|
40
83
|
just before the call, and ChatGPT's plan is reached through the Responses API
|
|
41
84
|
(streamed, unstored) rather than chat completions. `llm -m` cannot do either,
|
|
42
85
|
so for those models use `python metrics/client.py <alias> "prompt"`.
|
|
43
86
|
|
|
87
|
+
NATIVE ANTHROPIC AND GEMINI: an endpoint whose `api` is `anthropic` or
|
|
88
|
+
`gemini` (the Add endpoint form) is called in that API, through here
|
|
89
|
+
whatever BENCH_CLIENT says: metrics/formats.py builds the request (system
|
|
90
|
+
text apart, the model's options it knows, images as the API's own parts),
|
|
91
|
+
the key goes as x-api-key or x-goog-api-key, and the stream is read into
|
|
92
|
+
the same result, usage and finish included. Anthropic's error events read
|
|
93
|
+
as the status that carries them (overloaded_error is 529), so the text
|
|
94
|
+
contract holds. Tool calls are not sent to them (route mode proxies chat
|
|
95
|
+
completions only).
|
|
96
|
+
|
|
97
|
+
FOR A PROXY (the gateway's route mode): `body`, `on_chunk`, `first_token`
|
|
98
|
+
and `retries` (see chat()) pass a request's own fields, relay each chunk
|
|
99
|
+
exactly, give up early on a member that sends nothing, and leave failover
|
|
100
|
+
to the caller.
|
|
101
|
+
|
|
44
102
|
RETRIES MATCH THE SDK UNDER llm: two more tries on a connection error, 408,
|
|
45
103
|
409, 429 or 5xx, waiting the Retry-After when it is 60 s or less, else about
|
|
46
104
|
0.5 then 1 s. Without them this client would surface 429s llm absorbed, and
|
|
47
105
|
the comparison would blame the client. A wait that would pass the deadline is
|
|
48
106
|
not taken: the error is returned, which says more than a timeout.
|
|
49
107
|
|
|
50
|
-
Not yet (Phase 2b): streaming, usage ledger, quota records, enforcing learned
|
|
51
|
-
limits. Non-streaming means a long reasoning answer behind a proxy with a
|
|
52
|
-
short idle timeout can fail here where llm's stream survived; the perf tier's
|
|
53
|
-
one-word prompts are where this is tried first.
|
|
54
|
-
|
|
55
108
|
python scripts/test_client.py # both backends on a fake server, same results
|
|
109
|
+
python scripts/test_quota.py # the ledger, quota records and pacing, on a fake server
|
|
56
110
|
"""
|
|
57
111
|
|
|
58
112
|
from __future__ import annotations
|
|
59
113
|
|
|
114
|
+
import datetime as dt
|
|
60
115
|
import email.utils
|
|
61
116
|
import http.client
|
|
62
117
|
import json
|
|
@@ -65,8 +120,10 @@ import pathlib
|
|
|
65
120
|
import random
|
|
66
121
|
import shutil
|
|
67
122
|
import sys
|
|
123
|
+
import threading
|
|
68
124
|
import time
|
|
69
125
|
import urllib.error
|
|
126
|
+
import urllib.parse
|
|
70
127
|
import urllib.request
|
|
71
128
|
|
|
72
129
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
@@ -74,11 +131,13 @@ from paths import DATA # noqa: E402 -- the install's data folder (metrics/path
|
|
|
74
131
|
sys.path.insert(0, str(ROOT / "metrics"))
|
|
75
132
|
import connect # noqa: E402
|
|
76
133
|
import endpoints # noqa: E402
|
|
134
|
+
import formats # noqa: E402
|
|
77
135
|
|
|
78
136
|
OPTIONS = DATA / "config" / "model-options.json"
|
|
79
137
|
MAX_RETRIES = 2
|
|
80
138
|
RETRY_STATUS = {408, 409, 429}
|
|
81
139
|
MAX_REPLY = 32_000_000 # bytes
|
|
140
|
+
STOP = threading.Event() # set by a server shutting down
|
|
82
141
|
|
|
83
142
|
|
|
84
143
|
CLIENTS = ("llm", "builtin")
|
|
@@ -103,11 +162,12 @@ def chosen() -> tuple[str, str]:
|
|
|
103
162
|
|
|
104
163
|
def backend(alias: str = "") -> str:
|
|
105
164
|
"""`llm` or `builtin`, as chosen(). A model on an endpoint connected by
|
|
106
|
-
sign-in whose token renews, or that speaks
|
|
107
|
-
(ChatGPT's plan
|
|
165
|
+
sign-in whose token renews, or that speaks another API than chat
|
|
166
|
+
completions (ChatGPT's plan, native Anthropic or Gemini), is always
|
|
167
|
+
`builtin`: llm can do neither."""
|
|
108
168
|
if alias:
|
|
109
169
|
entry = endpoints.entry_of(alias)
|
|
110
|
-
if entry.get("api")
|
|
170
|
+
if (entry.get("api") or "chat") != "chat" or (entry.get("connect") or {}).get("refreshes"):
|
|
111
171
|
return "builtin"
|
|
112
172
|
return chosen()[0]
|
|
113
173
|
|
|
@@ -163,87 +223,338 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
|
163
223
|
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
164
224
|
|
|
165
225
|
|
|
166
|
-
|
|
167
|
-
"""The
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
226
|
+
class _Late(Exception):
|
|
227
|
+
"""The deadline passed while the reply was arriving."""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _Broken(Exception):
|
|
231
|
+
"""The reply broke off part way (the connection dropped mid-stream)."""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _wait_at_most(response, deadline: float) -> None:
|
|
235
|
+
"""The socket's timeout is per wait, so it is cut to what is left of the
|
|
236
|
+
deadline before each read: a server trickling bytes cannot hold a seat
|
|
237
|
+
past it. Best effort: the socket sits three wrappers down."""
|
|
238
|
+
sock = getattr(getattr(getattr(response, "fp", None), "raw", None), "_sock", None)
|
|
239
|
+
if sock is not None:
|
|
240
|
+
try:
|
|
241
|
+
sock.settimeout(max(deadline - time.monotonic(), 0.01))
|
|
242
|
+
except OSError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _chunks(response, deadline: float, relay: _Relay | None = None):
|
|
247
|
+
"""The body in pieces as they arrive. Raises _Late past the deadline (or
|
|
248
|
+
the relay's first-piece cutoff, or when STOP is set), ValueError past
|
|
249
|
+
MAX_REPLY, since the gateway is a long-lived process, and _Broken if the
|
|
250
|
+
connection drops part way."""
|
|
251
|
+
size = 0
|
|
252
|
+
while True:
|
|
253
|
+
limit = relay.cutoff(deadline) if relay else deadline
|
|
254
|
+
if STOP.is_set() or time.monotonic() > limit:
|
|
255
|
+
raise _Late
|
|
256
|
+
_wait_at_most(response, limit)
|
|
257
|
+
try:
|
|
258
|
+
chunk = response.read1(65536)
|
|
259
|
+
except TimeoutError:
|
|
260
|
+
raise _Late from None
|
|
261
|
+
except (OSError, http.client.HTTPException) as e:
|
|
262
|
+
raise _Broken(str(e) or type(e).__name__) from None
|
|
263
|
+
if not chunk:
|
|
264
|
+
return
|
|
173
265
|
size += len(chunk)
|
|
174
266
|
if size > MAX_REPLY:
|
|
175
267
|
raise ValueError(f"the reply is over {MAX_REPLY // 1_000_000} MB")
|
|
176
|
-
|
|
177
|
-
if time.monotonic() > deadline:
|
|
178
|
-
return None
|
|
179
|
-
return b"".join(chunks)
|
|
268
|
+
yield chunk
|
|
180
269
|
|
|
181
270
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
271
|
+
def _lines(chunks):
|
|
272
|
+
"""Decoded lines from byte chunks, split as they arrive."""
|
|
273
|
+
buf = b""
|
|
274
|
+
for chunk in chunks:
|
|
275
|
+
buf += chunk
|
|
276
|
+
*done, buf = buf.split(b"\n")
|
|
277
|
+
for line in done:
|
|
278
|
+
yield line.rstrip(b"\r").decode("utf-8", "replace")
|
|
279
|
+
if buf:
|
|
280
|
+
yield buf.rstrip(b"\r").decode("utf-8", "replace")
|
|
192
281
|
|
|
193
|
-
def done(**kw) -> dict:
|
|
194
|
-
res.update(kw, seconds=round(time.monotonic() - started, 1))
|
|
195
|
-
return res
|
|
196
282
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
283
|
+
def _events(lines):
|
|
284
|
+
"""Server-sent events' data, parsed: a dict per event, the string
|
|
285
|
+
"[DONE]" for the end marker. Comments (`: OPENROUTER PROCESSING`) and
|
|
286
|
+
event names are skipped; data lines of one event are joined, or, when
|
|
287
|
+
they do not parse joined, read one by one: some servers leave out the
|
|
288
|
+
blank line between events."""
|
|
289
|
+
data: list[str] = []
|
|
290
|
+
|
|
291
|
+
def flush() -> list:
|
|
292
|
+
lines, payload = list(data), "\n".join(data).strip()
|
|
293
|
+
data.clear()
|
|
294
|
+
if not payload:
|
|
295
|
+
return []
|
|
296
|
+
try:
|
|
297
|
+
return ["[DONE]" if payload == "[DONE]" else json.loads(payload)]
|
|
298
|
+
except ValueError:
|
|
299
|
+
pass
|
|
300
|
+
out = []
|
|
301
|
+
for one in lines:
|
|
302
|
+
try:
|
|
303
|
+
out.append("[DONE]" if one.strip() == "[DONE]" else json.loads(one))
|
|
304
|
+
except ValueError:
|
|
305
|
+
continue
|
|
306
|
+
return out
|
|
307
|
+
for line in lines:
|
|
308
|
+
if not line:
|
|
309
|
+
yield from flush()
|
|
310
|
+
elif line.startswith("data:"):
|
|
311
|
+
data.append(line[5:].lstrip(" "))
|
|
312
|
+
yield from flush()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _Relay:
|
|
316
|
+
"""on_delta and on_chunk, kept from breaking the call: one that raises is
|
|
317
|
+
dropped. `first_by`: when the first piece must have come by (monotonic),
|
|
318
|
+
for a caller that would rather try another model than wait (route mode)."""
|
|
319
|
+
|
|
320
|
+
def __init__(self, on_delta, started: float, on_chunk=None, first_by: float | None = None):
|
|
321
|
+
self.fn, self.started, self.first = on_delta, started, None
|
|
322
|
+
self.chunk_fn, self.first_by = on_chunk, first_by
|
|
323
|
+
|
|
324
|
+
def mark(self) -> None:
|
|
325
|
+
"""The first piece (text, reasoning or a tool call) has arrived."""
|
|
326
|
+
if self.first is None:
|
|
327
|
+
self.first = round(time.monotonic() - self.started, 2)
|
|
328
|
+
|
|
329
|
+
def cutoff(self, deadline: float) -> float:
|
|
330
|
+
"""What a wait may last until: the deadline, or sooner while the first piece is still awaited."""
|
|
331
|
+
return min(deadline, self.first_by) if self.first is None and self.first_by is not None else deadline
|
|
332
|
+
|
|
333
|
+
def __call__(self, kind: str, piece: str) -> None:
|
|
334
|
+
if not piece:
|
|
335
|
+
return
|
|
336
|
+
self.mark()
|
|
337
|
+
if self.fn is not None:
|
|
338
|
+
try:
|
|
339
|
+
self.fn(kind, piece)
|
|
340
|
+
except Exception: # noqa: BLE001
|
|
341
|
+
self.fn = None
|
|
342
|
+
|
|
343
|
+
def chunk(self, chunk: dict) -> None:
|
|
344
|
+
if self.chunk_fn is not None:
|
|
345
|
+
try:
|
|
346
|
+
self.chunk_fn(chunk)
|
|
347
|
+
except Exception: # noqa: BLE001
|
|
348
|
+
self.chunk_fn = None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _chat_stream(events, relay: _Relay) -> dict:
|
|
352
|
+
"""A streamed chat completion as chat()'s fields. Each chunk that is not
|
|
353
|
+
an error goes to relay.chunk as it came, for a proxy (route mode)."""
|
|
354
|
+
text, reasoning, model, usage, finish, seen, done = [], [], "", {}, "", False, False
|
|
355
|
+
calls: set = set()
|
|
356
|
+
for ev in events:
|
|
357
|
+
if ev == "[DONE]":
|
|
358
|
+
done = True
|
|
359
|
+
break
|
|
360
|
+
if not isinstance(ev, dict):
|
|
361
|
+
continue
|
|
362
|
+
# An error sent mid-stream, as some relays do, or as OpenRouter does:
|
|
363
|
+
# with a choice whose finish_reason is "error", after part of an answer.
|
|
364
|
+
choices = ev.get("choices")
|
|
365
|
+
if ev.get("error") or (isinstance(choices, list) and choices and isinstance(choices[0], dict)
|
|
366
|
+
and choices[0].get("finish_reason") == "error"):
|
|
367
|
+
err = ev.get("error") or {}
|
|
368
|
+
code = (err.get("code") if isinstance(err, dict) else None) or "stream"
|
|
369
|
+
return {"error": f"Error: Error code: {code} - {ev}"}
|
|
370
|
+
model = ev.get("model") or model
|
|
371
|
+
if isinstance(ev.get("usage"), dict) and ev["usage"]:
|
|
372
|
+
usage = ev["usage"]
|
|
373
|
+
if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
|
|
374
|
+
relay.chunk(ev)
|
|
375
|
+
continue
|
|
376
|
+
seen = True
|
|
377
|
+
delta = choices[0].get("delta") or choices[0].get("message") or {}
|
|
378
|
+
if isinstance(delta, dict):
|
|
379
|
+
if piece := _text(delta.get("reasoning_content") or delta.get("reasoning")):
|
|
380
|
+
reasoning.append(piece)
|
|
381
|
+
relay("reasoning", piece)
|
|
382
|
+
if piece := _text(delta.get("content")):
|
|
383
|
+
text.append(piece)
|
|
384
|
+
relay("text", piece)
|
|
385
|
+
for tc in delta.get("tool_calls") or ():
|
|
386
|
+
if isinstance(tc, dict):
|
|
387
|
+
calls.add(tc["index"] if tc.get("index") is not None else tc.get("id"))
|
|
388
|
+
relay.mark()
|
|
389
|
+
relay.chunk(ev)
|
|
390
|
+
finish = choices[0].get("finish_reason") or finish
|
|
391
|
+
if not seen:
|
|
392
|
+
return {"error": "Error: client failed: the reply had no choices: the stream sent none"}
|
|
393
|
+
if not (done or finish):
|
|
394
|
+
return {"error": f"Error: client failed: the stream broke off before it finished"
|
|
395
|
+
f" ({len(''.join(text))} characters in)"}
|
|
396
|
+
return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model, "usage": usage,
|
|
397
|
+
"finish": finish, "tool_calls": len(calls)}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _chat_json(raw: bytes, relay: _Relay | None = None) -> dict:
|
|
401
|
+
"""A plain JSON chat completion (an upstream that does not stream) as
|
|
402
|
+
chat()'s fields; to relay.chunk as the one chunk it would have streamed."""
|
|
403
|
+
try:
|
|
404
|
+
data = json.loads(raw or b"{}")
|
|
405
|
+
except ValueError:
|
|
406
|
+
return {"error": "Error: client failed: the reply was not JSON: " + raw[:1000].decode("utf-8", "replace")}
|
|
407
|
+
if not isinstance(data, dict):
|
|
408
|
+
return {"error": f"Error: client failed: unexpected reply: {str(data)[:1000]}"}
|
|
409
|
+
# Some relays answer 200 with an error object instead of a status.
|
|
410
|
+
if data.get("error") and not data.get("choices"):
|
|
411
|
+
code = (data["error"].get("code") if isinstance(data["error"], dict) else None) or 200
|
|
412
|
+
return {"error": f"Error: Error code: {code} - {data}"}
|
|
413
|
+
choices = data.get("choices")
|
|
414
|
+
# A 200 with no choice is a broken endpoint, not an empty answer: "empty"
|
|
415
|
+
# is kept out of auto-retirement as a budget problem.
|
|
416
|
+
if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
|
|
417
|
+
return {"error": f"Error: client failed: the reply had no choices: {str(data)[:1000]}"}
|
|
418
|
+
message = choices[0].get("message") or {}
|
|
419
|
+
calls = [tc for tc in message.get("tool_calls") or () if isinstance(tc, dict)]
|
|
420
|
+
out = {"text": _text(message.get("content")),
|
|
421
|
+
"reasoning": _text(message.get("reasoning_content") or message.get("reasoning")),
|
|
422
|
+
"model": data.get("model") or "", "usage": data.get("usage") or {},
|
|
423
|
+
"finish": choices[0].get("finish_reason") or "", "tool_calls": len(calls)}
|
|
424
|
+
if relay is not None:
|
|
425
|
+
if out["text"] or out["reasoning"] or calls:
|
|
426
|
+
relay.mark()
|
|
427
|
+
delta = {"role": "assistant", **{k: message[k] for k in ("content", "reasoning_content", "reasoning")
|
|
428
|
+
if message.get(k)}}
|
|
429
|
+
if calls:
|
|
430
|
+
delta["tool_calls"] = [{"index": i, **tc} for i, tc in enumerate(calls)]
|
|
431
|
+
relay.chunk({"id": data.get("id") or "", "object": "chat.completion.chunk", "created": data.get("created"),
|
|
432
|
+
"model": out["model"], "usage": out["usage"] or None,
|
|
433
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": out["finish"] or "stop"}]})
|
|
434
|
+
return out
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _reply(response, deadline: float, api: str, relay: _Relay) -> dict:
|
|
438
|
+
"""The reply's fields, read as it arrives, in the endpoint's API (`api`:
|
|
439
|
+
chat, responses, anthropic or gemini). Raises _Late past the deadline."""
|
|
440
|
+
ctype = (response.headers.get("content-type") or "").lower()
|
|
441
|
+
stream = {"responses": _responses_events, "anthropic": _anthropic_events, "gemini": _gemini_events}
|
|
442
|
+
try:
|
|
443
|
+
if api == "responses" or (api in stream and "event-stream" in ctype):
|
|
444
|
+
return stream[api](_events(_lines(_chunks(response, deadline, relay))), relay)
|
|
445
|
+
if "event-stream" in ctype:
|
|
446
|
+
return _chat_stream(_events(_lines(_chunks(response, deadline, relay))), relay)
|
|
447
|
+
raw = b"".join(_chunks(response, deadline, relay))
|
|
448
|
+
except _Broken as e:
|
|
449
|
+
# Not retried: what arrived may already have been relayed.
|
|
450
|
+
return {"error": f"Error: client failed: the reply broke off part way ({e})"}
|
|
451
|
+
if api in stream: # a native API answering with one JSON body
|
|
452
|
+
try:
|
|
453
|
+
return stream[api]([json.loads(raw or b"{}")], relay)
|
|
454
|
+
except ValueError:
|
|
455
|
+
return {"error": "Error: client failed: the reply was not JSON: " + raw[:1000].decode("utf-8", "replace")}
|
|
456
|
+
if raw.lstrip().startswith(b"data:"): # a stream sent without its content type
|
|
457
|
+
return _chat_stream(_events(_lines([raw])), relay)
|
|
458
|
+
return _chat_json(raw, relay)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# Anthropic's error types as the HTTP statuses that carry them, so an error
|
|
462
|
+
# sent mid-stream reads with the same markers (429, 529) as one sent as a status.
|
|
463
|
+
ANTHROPIC_STATUS = {"invalid_request_error": 400, "authentication_error": 401, "permission_error": 403,
|
|
464
|
+
"not_found_error": 404, "request_too_large": 413, "rate_limit_error": 429,
|
|
465
|
+
"api_error": 500, "overloaded_error": 529}
|
|
466
|
+
ANTHROPIC_FINISH = {"end_turn": "stop", "stop_sequence": "stop", "max_tokens": "length", "tool_use": "tool_calls"}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _anthropic_events(events, relay: _Relay) -> dict:
|
|
470
|
+
"""A native Anthropic Messages stream (or one whole reply) as chat()'s
|
|
471
|
+
fields. Finished only once message_stop (or a whole message) arrives."""
|
|
472
|
+
text, reasoning, model, usage, finish, done = [], [], "", {}, "", False
|
|
473
|
+
for ev in events:
|
|
474
|
+
if not isinstance(ev, dict):
|
|
475
|
+
continue
|
|
476
|
+
kind = ev.get("type")
|
|
477
|
+
if kind == "error":
|
|
478
|
+
err = ev.get("error") if isinstance(ev.get("error"), dict) else {}
|
|
479
|
+
return {"error": f"Error: Error code: {ANTHROPIC_STATUS.get(err.get('type'), err.get('type') or 'stream')} - {ev}"}
|
|
480
|
+
if kind == "message": # a whole reply, not a stream
|
|
481
|
+
text.append(formats.anthropic_text(ev))
|
|
482
|
+
relay("text", text[-1])
|
|
483
|
+
model, usage, finish, done = ev.get("model") or "", ev.get("usage") or {}, ev.get("stop_reason") or "", True
|
|
484
|
+
elif kind == "message_start":
|
|
485
|
+
message = ev.get("message") or {}
|
|
486
|
+
model, usage = message.get("model") or "", dict(message.get("usage") or {})
|
|
487
|
+
elif kind == "content_block_delta":
|
|
488
|
+
delta = ev.get("delta") or {}
|
|
489
|
+
if delta.get("type") == "text_delta":
|
|
490
|
+
text.append(delta.get("text") or "")
|
|
491
|
+
relay("text", delta.get("text") or "")
|
|
492
|
+
elif delta.get("type") == "thinking_delta":
|
|
493
|
+
reasoning.append(delta.get("thinking") or "")
|
|
494
|
+
relay("reasoning", delta.get("thinking") or "")
|
|
495
|
+
elif kind == "message_delta":
|
|
496
|
+
finish = (ev.get("delta") or {}).get("stop_reason") or finish
|
|
497
|
+
usage.update({k: v for k, v in (ev.get("usage") or {}).items() if v is not None})
|
|
498
|
+
elif kind == "message_stop":
|
|
499
|
+
done = True
|
|
500
|
+
if not done:
|
|
501
|
+
return {"error": f"Error: client failed: the stream broke off before it finished ({len(''.join(text))} characters in)"}
|
|
502
|
+
return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model, "usage": usage,
|
|
503
|
+
"finish": ANTHROPIC_FINISH.get(finish, finish)}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _gemini_events(events, relay: _Relay) -> dict:
|
|
507
|
+
"""A native Gemini generateContent stream (or one whole reply) as chat()'s
|
|
508
|
+
fields. A stream has no end marker: it is finished once a candidate says
|
|
509
|
+
why it stopped."""
|
|
510
|
+
text, reasoning, model, usage, finish = [], [], "", {}, ""
|
|
511
|
+
for ev in events:
|
|
512
|
+
if not isinstance(ev, dict):
|
|
513
|
+
continue
|
|
514
|
+
if ev.get("error"):
|
|
515
|
+
err = ev["error"] if isinstance(ev["error"], dict) else {}
|
|
516
|
+
return {"error": f"Error: Error code: {err.get('code') or 'stream'} - {ev}"}
|
|
517
|
+
if (blocked := (ev.get("promptFeedback") or {}).get("blockReason")):
|
|
518
|
+
return {"error": f"Error: client failed: the prompt was blocked ({blocked}): {ev}"}
|
|
519
|
+
model = ev.get("modelVersion") or model
|
|
520
|
+
if (meta := ev.get("usageMetadata")):
|
|
521
|
+
usage = {"prompt_tokens": meta.get("promptTokenCount"), "completion_tokens": meta.get("candidatesTokenCount"),
|
|
522
|
+
**({"completion_tokens_details": {"reasoning_tokens": meta["thoughtsTokenCount"]}}
|
|
523
|
+
if meta.get("thoughtsTokenCount") else {})}
|
|
524
|
+
candidate = (ev.get("candidates") or [{}])[0]
|
|
525
|
+
for part in (candidate.get("content") or {}).get("parts") or ():
|
|
526
|
+
if part.get("text"):
|
|
527
|
+
(reasoning if part.get("thought") else text).append(part["text"])
|
|
528
|
+
relay("reasoning" if part.get("thought") else "text", part["text"])
|
|
529
|
+
finish = candidate.get("finishReason") or finish
|
|
530
|
+
if not finish:
|
|
531
|
+
return {"error": f"Error: client failed: the stream broke off before it finished ({len(''.join(text))} characters in)"}
|
|
532
|
+
return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model,
|
|
533
|
+
"usage": {k: v for k, v in usage.items() if v is not None},
|
|
534
|
+
"finish": {"STOP": "stop", "MAX_TOKENS": "length"}.get(finish, finish.lower())}
|
|
234
535
|
|
|
235
|
-
|
|
236
|
-
|
|
536
|
+
|
|
537
|
+
def _exchange(url: str, payload: dict, headers: dict, deadline: float, api: str,
|
|
538
|
+
relay: _Relay, observe, retries: int = MAX_RETRIES) -> dict:
|
|
539
|
+
"""Send the call with the SDK's retries (`retries` more tries); chat()'s
|
|
540
|
+
fields plus `attempts`. `observe(status, headers, body)` sees every
|
|
541
|
+
response, for quota records."""
|
|
542
|
+
attempts, attempt, fell_back = 0, 0, False
|
|
543
|
+
while True:
|
|
544
|
+
left = relay.cutoff(deadline) - time.monotonic()
|
|
237
545
|
if left <= 0:
|
|
238
|
-
return
|
|
239
|
-
request = urllib.request.Request(url,
|
|
546
|
+
return {"timed_out": True, "attempts": attempts}
|
|
547
|
+
request = urllib.request.Request(url, json.dumps(payload).encode(), headers)
|
|
240
548
|
status = 0
|
|
549
|
+
attempts += 1
|
|
241
550
|
try:
|
|
242
551
|
with _OPENER.open(request, timeout=left) as r:
|
|
243
552
|
status = r.status
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
553
|
+
observe(status, r.headers, None)
|
|
554
|
+
got = _reply(r, deadline, api, relay)
|
|
555
|
+
return {"status": status, "attempts": attempts, **got}
|
|
556
|
+
except _Late:
|
|
557
|
+
return {"status": status, "timed_out": True, "attempts": attempts}
|
|
247
558
|
except urllib.error.HTTPError as e:
|
|
248
559
|
try:
|
|
249
560
|
text = e.read().decode("utf-8", "replace").strip()
|
|
@@ -253,72 +564,318 @@ def chat(alias: str, prompt: str, system: str = "", timeout: float = 420,
|
|
|
253
564
|
shown = json.loads(text)
|
|
254
565
|
except ValueError:
|
|
255
566
|
shown = text
|
|
567
|
+
observe(e.code, e.headers, shown)
|
|
568
|
+
# An upstream that does not know stream_options, or will not stream:
|
|
569
|
+
# the same call once more without them, not counted as a retry.
|
|
570
|
+
if (api == "chat" and not fell_back and e.code in (400, 422) and payload.get("stream")
|
|
571
|
+
and "stream" in text.lower()):
|
|
572
|
+
drop = ("stream_options",) if "stream_options" in text.lower() else ("stream", "stream_options")
|
|
573
|
+
payload = {k: v for k, v in payload.items() if k not in drop}
|
|
574
|
+
fell_back = True
|
|
575
|
+
continue
|
|
256
576
|
wait = _retry_after(e.headers)
|
|
257
577
|
err = f"Error: Error code: {e.code}" + (f" - {shown}" if text else "") \
|
|
258
578
|
+ (f" Retry-After: {e.headers.get('retry-after')}" if e.headers.get("retry-after") else "") \
|
|
259
579
|
+ (f" (redirect to {e.headers.get('location')} not followed)" if 300 <= e.code < 400 else "")
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return done(error=err)
|
|
580
|
+
if not (e.code in RETRY_STATUS or e.code >= 500) or attempt >= retries:
|
|
581
|
+
return {"status": e.code, "error": err, "attempts": attempts}
|
|
263
582
|
wait = wait or _backoff(attempt)
|
|
264
|
-
if time.monotonic() + wait >= deadline:
|
|
265
|
-
return
|
|
583
|
+
if time.monotonic() + wait >= relay.cutoff(deadline):
|
|
584
|
+
return {"status": e.code, "error": err, "attempts": attempts}
|
|
266
585
|
time.sleep(wait)
|
|
267
|
-
|
|
586
|
+
attempt += 1
|
|
268
587
|
except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
|
|
269
588
|
reason = getattr(e, "reason", e)
|
|
270
589
|
# The deadline itself, or the OS giving up on a connection early
|
|
271
590
|
# (a dropped SYN), which is a connection error and is retried.
|
|
272
|
-
if isinstance(reason, TimeoutError) and time.monotonic() >= deadline - 0.5:
|
|
273
|
-
return
|
|
591
|
+
if isinstance(reason, TimeoutError) and time.monotonic() >= relay.cutoff(deadline) - 0.5:
|
|
592
|
+
return {"timed_out": True, "attempts": attempts}
|
|
274
593
|
err = f"Error: client failed: Connection error. ({reason})"
|
|
275
594
|
wait = _backoff(attempt)
|
|
276
|
-
if attempt
|
|
277
|
-
return
|
|
595
|
+
if attempt >= retries or time.monotonic() + wait >= relay.cutoff(deadline):
|
|
596
|
+
return {"error": err, "attempts": attempts}
|
|
278
597
|
time.sleep(wait)
|
|
279
|
-
|
|
598
|
+
attempt += 1
|
|
280
599
|
except ValueError as e: # over MAX_REPLY, or a malformed URL
|
|
281
|
-
return
|
|
282
|
-
|
|
283
|
-
|
|
600
|
+
return {"status": status, "error": f"Error: client failed: {e}", "attempts": attempts}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _estimate(prompt: str, system: str, messages: list[dict] | None) -> int:
|
|
604
|
+
"""Tokens a prompt will take, roughly (four characters a token), for
|
|
605
|
+
pacing a tokens-per-minute limit before the provider has counted."""
|
|
606
|
+
if messages is None:
|
|
607
|
+
return (len(prompt) + len(system)) // 4 + 1
|
|
608
|
+
return sum(len(m.get("content") if isinstance(m.get("content"), str) else _text(m.get("content")))
|
|
609
|
+
for m in messages if isinstance(m, dict)) // 4 + 1
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _paid_hold(alias: str, served: str) -> str:
|
|
613
|
+
"""The free-tier guard's refusal, or "" when the model may be called."""
|
|
614
|
+
if os.environ.get("BENCH_ALLOW_PAID") == "1" or alias in (endpoints.connections().get("allow_paid") or ()):
|
|
615
|
+
return ""
|
|
616
|
+
try:
|
|
617
|
+
import alerts
|
|
618
|
+
alert = alerts.active().get(f"model:paid:{served}") or {}
|
|
619
|
+
except Exception: # noqa: BLE001
|
|
620
|
+
return ""
|
|
621
|
+
if alert.get("kind") != "went paid":
|
|
622
|
+
return ""
|
|
623
|
+
return (f"Error: client failed: not sent: the free-tier guard holds {alias}, which is listed with a"
|
|
624
|
+
f" price now ({alert.get('message', '')[:160]}), so a call may be billed: payment required"
|
|
625
|
+
f" is assumed. Allow it by adding {alias!r} to allow_paid in config/connections.json,"
|
|
626
|
+
f" or for one run with BENCH_ALLOW_PAID=1.")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _budget(payload: dict, usage: dict, alias: str) -> tuple[str, int, int] | None:
|
|
630
|
+
"""(option name, budget used, the larger budget) for retrying an empty
|
|
631
|
+
answer that ran out of output budget, or None when it cannot grow."""
|
|
632
|
+
key = "max_completion_tokens" if "max_completion_tokens" in payload else "max_tokens"
|
|
633
|
+
try:
|
|
634
|
+
current = int(float(payload.get(key) or usage.get("completion_tokens") or 0))
|
|
635
|
+
except (TypeError, ValueError):
|
|
636
|
+
return None
|
|
637
|
+
if current <= 0:
|
|
638
|
+
return None
|
|
639
|
+
bigger = current * 2
|
|
640
|
+
try:
|
|
641
|
+
import catalog
|
|
642
|
+
ceiling = catalog.info(alias).get("max_output")
|
|
643
|
+
except Exception: # noqa: BLE001
|
|
644
|
+
ceiling = None
|
|
645
|
+
if isinstance(ceiling, int) and ceiling > 0:
|
|
646
|
+
bigger = min(bigger, ceiling)
|
|
647
|
+
return (key, current, bigger) if bigger > current else None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _swapped(requested: str, served: str) -> bool:
|
|
651
|
+
"""The reply names another model, beyond spelling: a vendor prefix, `:free`
|
|
652
|
+
and a date or version tail are the same model (sources.norm_model)."""
|
|
653
|
+
if not served or not requested or served == requested:
|
|
654
|
+
return False
|
|
655
|
+
import sources
|
|
656
|
+
return sources.norm_model(served) != sources.norm_model(requested)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _caller() -> str:
|
|
660
|
+
"""The script making the call: gateway, perf/run, suite/run, serve, ..."""
|
|
661
|
+
p = pathlib.Path(sys.argv[0] if sys.argv and sys.argv[0] else "python")
|
|
662
|
+
return f"{p.parent.name}/{p.stem}" if p.parent.name in ("perf", "suite", "scripts", "metrics") else p.stem
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def chat(alias: str, prompt: str, system: str = "", timeout: float = 420,
|
|
666
|
+
messages: list[dict] | None = None, on_delta=None, purpose: str = "",
|
|
667
|
+
body: dict | None = None, on_chunk=None, first_token: float | None = None,
|
|
668
|
+
retries: int | None = None) -> dict:
|
|
669
|
+
"""One chat completion. Returns {text, reasoning, error, timed_out, status,
|
|
670
|
+
model (as served), usage, finish, tool_calls (how many), first_token_s,
|
|
671
|
+
attempts, seconds}, and `budget_retry` when an empty answer was tried
|
|
672
|
+
again; `error` is "" on success, else the contract text. `messages`, when
|
|
673
|
+
given, are sent as they are instead of system + prompt: the gateway's
|
|
674
|
+
requests that carry images, which `llm -m` cannot send. `on_delta(kind,
|
|
675
|
+
text)` hears the answer ("text") and the reasoning ("reasoning") as they
|
|
676
|
+
arrive. `purpose` goes into the ledger line beside the calling script's name.
|
|
677
|
+
|
|
678
|
+
For a proxy (the gateway's route mode), chat completions only: `body`,
|
|
679
|
+
the request's other fields (tools, tool_choice, response_format,
|
|
680
|
+
temperature, ...), which win over the model's own options; `on_chunk`,
|
|
681
|
+
which hears each stream chunk exactly as it came (or the one a whole
|
|
682
|
+
reply would have been); `first_token`, seconds to wait for the first
|
|
683
|
+
piece (text, reasoning or a tool call) before giving up as timed out;
|
|
684
|
+
`retries`, how many more tries a 429, 5xx or connection error gets (the
|
|
685
|
+
SDK's two by default). An empty answer is not tried again: it is relayed."""
|
|
686
|
+
started, t0 = time.monotonic(), time.time()
|
|
687
|
+
deadline = started + timeout
|
|
688
|
+
res = {"text": "", "reasoning": "", "error": "", "timed_out": False, "status": 0,
|
|
689
|
+
"model": "", "usage": {}, "finish": "", "tool_calls": 0, "first_token_s": None, "attempts": 0}
|
|
690
|
+
ctx = {"connection": "", "endpoint": "", "ticket": None, "requested": "", "refused": False}
|
|
691
|
+
relay = _Relay(on_delta, started, on_chunk, started + first_token if first_token else None)
|
|
692
|
+
try:
|
|
693
|
+
_call(alias, prompt, system, messages, deadline, relay, res, ctx, body,
|
|
694
|
+
MAX_RETRIES if retries is None else retries)
|
|
695
|
+
finally:
|
|
696
|
+
res["seconds"] = round(time.monotonic() - started, 1)
|
|
697
|
+
_record(alias, res, ctx, t0, purpose)
|
|
698
|
+
return res
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# Anthropic's API version header: required on every call, and the one its
|
|
702
|
+
# Messages API has been served under since it launched.
|
|
703
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _request(api: str, base: str, model: str, alias: str, prompt: str, system: str,
|
|
707
|
+
messages: list[dict] | None, body: dict | None) -> tuple[str, dict]:
|
|
708
|
+
"""(url, payload) for one call in the endpoint's API. Raises ValueError
|
|
709
|
+
for what that API cannot be sent from here."""
|
|
710
|
+
if body is not None and api != "chat":
|
|
711
|
+
raise ValueError(f"a request's own fields (tools and the rest) go to chat completions only, not the {api} API")
|
|
712
|
+
if api == "responses":
|
|
713
|
+
# ChatGPT's plan (metrics/connect.py): streamed, unstored, no system
|
|
714
|
+
# message items, no sampling or length options.
|
|
715
|
+
if messages is not None:
|
|
716
|
+
# ponytail: chat parts are not Responses input items; converting
|
|
717
|
+
# them (input_text, input_image) waits for a caller that needs it.
|
|
718
|
+
raise ValueError("given messages are sent to chat completions only, not the Responses API")
|
|
719
|
+
return base + "/responses", {"model": model, "input": [{"role": "user", "content": prompt}],
|
|
720
|
+
"stream": True, "store": False, **({"instructions": system} if system else {})}
|
|
721
|
+
if messages is None:
|
|
722
|
+
messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
|
|
723
|
+
opts = _options(alias)
|
|
724
|
+
if api == "anthropic":
|
|
725
|
+
# formats.py builds the native request; the model's options that it
|
|
726
|
+
# knows (max_tokens, temperature, top_p, stop) go with it.
|
|
727
|
+
return base + "/messages", formats.anthropic_request(messages, model, opts, stream=True)
|
|
728
|
+
if api == "gemini":
|
|
729
|
+
# The base is the API's version root (.../v1beta), as its model list is under it.
|
|
730
|
+
return (f"{base}/models/{urllib.parse.quote(model.removeprefix('models/'), safe='-._~')}"
|
|
731
|
+
":streamGenerateContent?alt=sse", formats.gemini_request(messages, opts))
|
|
732
|
+
payload = {"model": model, "messages": messages, **opts}
|
|
733
|
+
if body:
|
|
734
|
+
if "max_completion_tokens" in body:
|
|
735
|
+
payload.pop("max_tokens", None) # both at once are refused by some APIs
|
|
736
|
+
payload.update({k: v for k, v in body.items() if k not in ("model", "messages", "stream", "stream_options")})
|
|
737
|
+
return base + "/chat/completions", {**payload, "stream": True, "stream_options": {"include_usage": True}}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _call(alias, prompt, system, messages, deadline, relay, res, ctx, body, retries) -> None:
|
|
741
|
+
reg = endpoints.registrations().get(alias)
|
|
742
|
+
if reg is None:
|
|
743
|
+
res["error"] = f"Error: client failed: no model is registered as {alias!r}"
|
|
744
|
+
return
|
|
745
|
+
base = (reg.get("api_base") or "").rstrip("/")
|
|
746
|
+
# urllib would also open file:// and ftp:// URLs given as a base.
|
|
747
|
+
if not base.startswith(("http://", "https://")):
|
|
748
|
+
res["error"] = f"Error: client failed: endpoint base must be http(s), not {base!r}"
|
|
749
|
+
return
|
|
750
|
+
model = ctx["requested"] = reg.get("model_name") or alias
|
|
751
|
+
api = endpoints.entry_of(alias).get("api") or "chat"
|
|
752
|
+
try:
|
|
753
|
+
url, payload = _request(api, base, model, alias, prompt, system, messages, body)
|
|
754
|
+
except ValueError as e: # formats.Unsupported is one too
|
|
755
|
+
res["error"] = f"Error: client failed: {e}"
|
|
756
|
+
return
|
|
757
|
+
headers = {"Content-Type": "application/json", "User-Agent": "benchwarmers",
|
|
758
|
+
**reg.get("headers", {})}
|
|
759
|
+
if api == "anthropic":
|
|
760
|
+
headers["anthropic-version"] = ANTHROPIC_VERSION
|
|
761
|
+
if name := reg.get("api_key_name", ""):
|
|
762
|
+
if why := connect.fresh(name): # a signed-in token past renewal
|
|
763
|
+
res["error"] = f"Error: client failed: {why}"
|
|
764
|
+
return
|
|
765
|
+
# As llm does: a named key that is not set stops here, rather than
|
|
766
|
+
# sending an unauthenticated call whose 401 would read as entitlement.
|
|
767
|
+
if not (key := endpoints.keys().get(name, "")):
|
|
768
|
+
res["error"] = f"Error: client failed: no key named {name!r} is set (llm keys set {name})"
|
|
769
|
+
return
|
|
770
|
+
headers.update(endpoints.auth_headers(api, key))
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
if str(ROOT / "suite") not in sys.path: # route reads the registry through suite/run.py
|
|
774
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
775
|
+
import route
|
|
776
|
+
ctx["connection"] = route.connection_of(alias)
|
|
777
|
+
ctx["endpoint"] = endpoints.endpoint_name(reg) or ""
|
|
778
|
+
except Exception: # noqa: BLE001 -- bookkeeping never stops a call
|
|
779
|
+
ctx["connection"] = ctx["connection"] or "?"
|
|
780
|
+
if why := _paid_hold(alias, model):
|
|
781
|
+
res["error"], ctx["refused"] = why, True
|
|
782
|
+
return
|
|
783
|
+
try:
|
|
784
|
+
import pacing
|
|
785
|
+
# A proxy that would rather try another model waits for room no
|
|
786
|
+
# longer than for a first piece. ponytail: the in-flight slot then
|
|
787
|
+
# expires that much sooner too; per-call expiry if it ever matters.
|
|
788
|
+
ctx["ticket"], why = pacing.admit(alias, ctx["connection"],
|
|
789
|
+
_estimate(prompt, system, messages) + len(json.dumps(body or "")) // 4,
|
|
790
|
+
relay.cutoff(deadline) - time.monotonic())
|
|
791
|
+
except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
|
|
792
|
+
why = ""
|
|
793
|
+
if why:
|
|
794
|
+
res["error"], ctx["refused"] = why, True
|
|
795
|
+
return
|
|
796
|
+
# The ledger's `t`: when the call went out, after any wait for room, since
|
|
797
|
+
# that is the moment a provider's window (and pacing's) counts it from.
|
|
798
|
+
ctx["sent"] = time.time()
|
|
799
|
+
|
|
800
|
+
def observe(status, hdrs, body) -> None:
|
|
284
801
|
try:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if not isinstance(data, dict):
|
|
290
|
-
return done(status=status, error=f"Error: client failed: unexpected reply: {str(data)[:1000]}")
|
|
291
|
-
# Some relays answer 200 with an error object instead of a status.
|
|
292
|
-
if data.get("error") and not data.get("choices"):
|
|
293
|
-
code = (data["error"].get("code") if isinstance(data["error"], dict) else None) or status
|
|
294
|
-
return done(status=status, error=f"Error: Error code: {code} - {data}")
|
|
295
|
-
choices = data.get("choices")
|
|
296
|
-
# A 200 with no choice is a broken endpoint, not an empty answer: "empty"
|
|
297
|
-
# is kept out of auto-retirement as a budget problem.
|
|
298
|
-
if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
|
|
299
|
-
return done(status=status, error=f"Error: client failed: the reply had no choices: {str(data)[:1000]}")
|
|
300
|
-
message = choices[0].get("message") or {}
|
|
301
|
-
return done(status=status, text=_text(message.get("content")),
|
|
302
|
-
reasoning=_text(message.get("reasoning_content") or message.get("reasoning")),
|
|
303
|
-
model=data.get("model") or "", usage=data.get("usage") or {})
|
|
304
|
-
return done(timed_out=True) # unreachable: every path above returns
|
|
802
|
+
import quota
|
|
803
|
+
quota.observe(alias, ctx["connection"], base, status, hdrs, body)
|
|
804
|
+
except Exception: # noqa: BLE001
|
|
805
|
+
pass
|
|
305
806
|
|
|
807
|
+
got = _exchange(url, payload, headers, deadline, api, relay, observe, retries)
|
|
808
|
+
# Not for a proxy: what it relayed already went to its caller.
|
|
809
|
+
if (api in ("chat", "anthropic") and relay.chunk_fn is None and not got.get("error")
|
|
810
|
+
and not got.get("timed_out") and got.get("text") == "" and not got.get("tool_calls")
|
|
811
|
+
and got.get("finish") == "length" and (grow := _budget(payload, got.get("usage") or {}, alias))):
|
|
812
|
+
key, used, bigger = grow
|
|
813
|
+
again = _exchange(url, {**payload, key: bigger}, headers, deadline, api, relay, observe, retries)
|
|
814
|
+
attempts = got.get("attempts", 0) + again.get("attempts", 0)
|
|
815
|
+
if again.get("error") or again.get("timed_out"):
|
|
816
|
+
# The empty answer stands: the retry's failure (a 400 for a budget
|
|
817
|
+
# over the model's cap, a 429) is not the model's, and an error
|
|
818
|
+
# here would count toward auto-retirement where "empty" does not.
|
|
819
|
+
got["budget_retry"] = {key: [used, bigger], "answered": False,
|
|
820
|
+
"failed": (again.get("error") or "timed out")[:200]}
|
|
821
|
+
else:
|
|
822
|
+
got = again
|
|
823
|
+
got["budget_retry"] = {key: [used, bigger], "answered": bool(again.get("text"))}
|
|
824
|
+
got["attempts"] = attempts
|
|
825
|
+
res.update(got)
|
|
826
|
+
res["first_token_s"] = relay.first
|
|
306
827
|
|
|
307
|
-
|
|
828
|
+
|
|
829
|
+
def _record(alias: str, res: dict, ctx: dict, t0: float, purpose: str) -> None:
|
|
830
|
+
"""The ledger line, the silent-swap note, and the in-flight slot freed.
|
|
831
|
+
Never raises: the answer matters more than its bookkeeping."""
|
|
832
|
+
try:
|
|
833
|
+
swap = not res["error"] and _swapped(ctx["requested"], res["model"])
|
|
834
|
+
if swap:
|
|
835
|
+
import learn
|
|
836
|
+
# Noted and logged when it is new; every line in the ledger says so.
|
|
837
|
+
if (learn.learned().get(alias, {}).get("served_as") or {}).get("served") != res["model"]:
|
|
838
|
+
learn.note_served_as(alias, ctx["requested"], res["model"])
|
|
839
|
+
print(f"note: {alias} asked for {ctx['requested']} and was served {res['model']}"
|
|
840
|
+
" (a silent swap?)", file=sys.stderr)
|
|
841
|
+
status = ("refused" if ctx["refused"] else "timeout" if res["timed_out"] else
|
|
842
|
+
"error" if res["error"] else "ok" if res["text"] or res.get("tool_calls") else "empty")
|
|
843
|
+
import ledger
|
|
844
|
+
t = ctx.get("sent") or t0
|
|
845
|
+
row = {"t": round(t, 3), "at": dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds"),
|
|
846
|
+
"alias": alias, "connection": ctx["connection"], "endpoint": ctx["endpoint"],
|
|
847
|
+
"served": res["model"], "status": status, "attempts": res.get("attempts", 0),
|
|
848
|
+
**ledger.tokens(res["usage"]), "seconds": res["seconds"],
|
|
849
|
+
"caller": _caller(), "purpose": purpose or os.environ.get("BENCH_PURPOSE", "")}
|
|
850
|
+
if swap:
|
|
851
|
+
row["swap"] = True
|
|
852
|
+
if res.get("budget_retry"):
|
|
853
|
+
row["budget_retry"] = res["budget_retry"]
|
|
854
|
+
ledger.append(row)
|
|
855
|
+
except Exception as e: # noqa: BLE001
|
|
856
|
+
# The slot stays taken until it expires (the call's deadline and a
|
|
857
|
+
# minute): released now, this call would count nowhere at all.
|
|
858
|
+
print(f"note: the usage ledger was not written: {e}", file=sys.stderr)
|
|
859
|
+
return
|
|
860
|
+
try:
|
|
861
|
+
import pacing
|
|
862
|
+
pacing.release(ctx["ticket"])
|
|
863
|
+
except Exception: # noqa: BLE001 -- the lock timed out: it expires instead
|
|
864
|
+
pass
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _responses_events(events, relay: _Relay) -> dict:
|
|
308
868
|
"""A streamed Responses API reply as chat()'s fields. It counts as an answer
|
|
309
869
|
only once `response.completed` arrives (OpenAI's rule for the plan flow); a
|
|
310
870
|
usage limit can arrive mid-stream as `response.failed`."""
|
|
311
871
|
deltas, final = [], None
|
|
312
|
-
for
|
|
313
|
-
if not line.startswith("data:"):
|
|
314
|
-
continue
|
|
315
|
-
try:
|
|
316
|
-
event = json.loads(line[5:].strip())
|
|
317
|
-
except ValueError:
|
|
318
|
-
continue
|
|
872
|
+
for event in events:
|
|
319
873
|
kind = event.get("type", "") if isinstance(event, dict) else ""
|
|
320
874
|
if kind == "response.output_text.delta":
|
|
321
875
|
deltas.append(event.get("delta") or "")
|
|
876
|
+
relay("text", event.get("delta") or "")
|
|
877
|
+
elif kind == "response.reasoning_summary_text.delta":
|
|
878
|
+
relay("reasoning", event.get("delta") or "")
|
|
322
879
|
elif kind in ("response.completed", "response.failed", "response.incomplete", "error"):
|
|
323
880
|
final = event
|
|
324
881
|
if final is None:
|
|
@@ -335,6 +892,11 @@ def _responses(raw: bytes) -> dict:
|
|
|
335
892
|
"usage": resp.get("usage") or {}}
|
|
336
893
|
|
|
337
894
|
|
|
895
|
+
def _responses(raw: bytes) -> dict:
|
|
896
|
+
"""The same, from a whole body (the self-check's canned streams)."""
|
|
897
|
+
return _responses_events(_events(_lines([raw])), _Relay(None, time.monotonic()))
|
|
898
|
+
|
|
899
|
+
|
|
338
900
|
def output(res: dict, timeout: float) -> str:
|
|
339
901
|
"""The result as ask_once's text: the answer as llm prints it (with its
|
|
340
902
|
closing newline), the error, or the timeout sentinel."""
|
|
@@ -353,14 +915,15 @@ def llm_log_failed_only(stdout: str, stderr: str) -> bool:
|
|
|
353
915
|
"pong") turned an answer into an error. llm writes the log only once the
|
|
354
916
|
response is complete, so stdout is the whole answer; the same lock in the
|
|
355
917
|
migration llm runs before the call leaves stdout empty and stays an error.
|
|
356
|
-
Not `llm -n`: metrics/cost.py reads that log for
|
|
918
|
+
Not `llm -n`: metrics/cost.py reads that log for the llm backend's tokens."""
|
|
357
919
|
lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
|
|
358
920
|
return bool((stdout or "").strip()) and bool(lines) and lines[-1] == LOG_LOCKED
|
|
359
921
|
|
|
360
922
|
|
|
361
923
|
def _selftest() -> None:
|
|
362
|
-
"""The
|
|
363
|
-
cut-off stream; and which `llm`
|
|
924
|
+
"""The stream parsers (chat completions and Responses): an answer with its
|
|
925
|
+
reasoning and usage, a mid-stream error, a cut-off stream; and which `llm`
|
|
926
|
+
failures are only its log."""
|
|
364
927
|
tb = "Traceback (most recent call last):\n File \"llm/cli.py\", in prompt\n" + LOG_LOCKED + "\n"
|
|
365
928
|
assert llm_log_failed_only("pong\n", tb)
|
|
366
929
|
assert not llm_log_failed_only("", tb) # locked before the call
|
|
@@ -374,6 +937,76 @@ def _selftest() -> None:
|
|
|
374
937
|
b'data: {"type":"response.failed","response":{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}}\n')
|
|
375
938
|
assert _responses(limit)["error"].startswith("Error: Error code: subscription_sharing_usage_limit_exceeded")
|
|
376
939
|
assert "before response.completed" in _responses(b'data: {"type":"response.output_text.delta","delta":"x"}\n')["error"]
|
|
940
|
+
|
|
941
|
+
def chunks(*evs): # each event split across two network reads
|
|
942
|
+
body = "".join((e if e.startswith(":") else f"data: {e}") + "\n\n" if isinstance(e, str)
|
|
943
|
+
else f"data: {json.dumps(e)}\n\n" for e in evs).encode()
|
|
944
|
+
return [body[:len(body) // 2], body[len(body) // 2:]]
|
|
945
|
+
heard = []
|
|
946
|
+
relay = _Relay(lambda k, t: heard.append((k, t)), time.monotonic())
|
|
947
|
+
got = _chat_stream(_events(_lines(chunks(
|
|
948
|
+
": OPENROUTER PROCESSING",
|
|
949
|
+
{"model": "m-2", "choices": [{"delta": {"reasoning_content": "hmm "}}]},
|
|
950
|
+
{"choices": [{"delta": {"reasoning": "so"}}]},
|
|
951
|
+
{"choices": [{"delta": {"content": "rea"}}]},
|
|
952
|
+
{"choices": [{"delta": {"content": [{"type": "text", "text": "dy"}]}, "finish_reason": "stop"}]},
|
|
953
|
+
{"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 5}}, "[DONE]"))), relay)
|
|
954
|
+
assert got == {"text": "ready", "reasoning": "hmm so", "model": "m-2", "finish": "stop", "tool_calls": 0,
|
|
955
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 5}}, got
|
|
956
|
+
assert heard == [("reasoning", "hmm "), ("reasoning", "so"), ("text", "rea"), ("text", "dy")], heard
|
|
957
|
+
assert relay.first is not None
|
|
958
|
+
cut = _chat_stream(_events(_lines(chunks({"choices": [{"delta": {"content": "half an"}}]}))), _Relay(None, 0))
|
|
959
|
+
assert "broke off before it finished (7 characters in)" in cut["error"], cut
|
|
960
|
+
err = _chat_stream(_events(_lines(chunks({"error": {"code": 429, "message": "Rate limit exceeded"}}))),
|
|
961
|
+
_Relay(None, 0))
|
|
962
|
+
assert err["error"].startswith("Error: Error code: 429 - "), err
|
|
963
|
+
# OpenRouter's mid-stream error: part of an answer, then a choice finishing "error".
|
|
964
|
+
err = _chat_stream(_events(_lines(chunks(
|
|
965
|
+
{"choices": [{"delta": {"content": "half"}}]},
|
|
966
|
+
{"error": {"code": "server_error", "message": "upstream died"},
|
|
967
|
+
"choices": [{"delta": {"content": ""}, "finish_reason": "error"}]}, "[DONE]"))), _Relay(None, 0))
|
|
968
|
+
assert err["error"].startswith("Error: Error code: server_error - "), err
|
|
969
|
+
assert "no choices" in _chat_stream(_events(_lines(chunks("[DONE]"))), _Relay(None, 0))["error"]
|
|
970
|
+
bad = _Relay(lambda k, t: 1 / 0, 0) # a callback that raises is dropped, the call goes on
|
|
971
|
+
bad("text", "x")
|
|
972
|
+
assert bad.fn is None
|
|
973
|
+
assert _chat_json(b'{"choices":[{"message":{"content":"","reasoning":"r"},"finish_reason":"length"}]}') \
|
|
974
|
+
== {"text": "", "reasoning": "r", "model": "", "usage": {}, "finish": "length", "tool_calls": 0}
|
|
975
|
+
# A proxy hears every chunk as it came, and a whole reply as the one chunk it would have streamed.
|
|
976
|
+
seen: list = []
|
|
977
|
+
relay = _Relay(None, 0, seen.append, first_by=0)
|
|
978
|
+
assert relay.cutoff(10) == 0 and _Relay(None, 0).cutoff(10) == 10 # awaiting the first piece
|
|
979
|
+
call = {"index": 0, "id": "c1", "function": {"name": "f", "arguments": "{}"}}
|
|
980
|
+
got = _chat_stream(_events(_lines(chunks({"choices": [{"delta": {"tool_calls": [call]}}]},
|
|
981
|
+
{"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}, "[DONE]"))), relay)
|
|
982
|
+
assert got["tool_calls"] == 1 and len(seen) == 2 and seen[0]["choices"][0]["delta"]["tool_calls"] == [call]
|
|
983
|
+
assert relay.cutoff(10) == 10 # a tool call is a first piece
|
|
984
|
+
seen.clear()
|
|
985
|
+
_chat_json(b'{"id":"x","choices":[{"message":{"content":null,"tool_calls":[{"id":"c1","function":{}}]},'
|
|
986
|
+
b'"finish_reason":"tool_calls"}]}', _Relay(None, 0, seen.append))
|
|
987
|
+
assert seen[0]["choices"][0]["delta"]["tool_calls"] == [{"index": 0, "id": "c1", "function": {}}], seen
|
|
988
|
+
# Native streams: Anthropic Messages and Gemini, read into the same fields.
|
|
989
|
+
got = _anthropic_events(_events(_lines(chunks(
|
|
990
|
+
{"type": "message_start", "message": {"model": "claude-x", "usage": {"input_tokens": 9}}},
|
|
991
|
+
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "hm"}},
|
|
992
|
+
{"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "ready"}},
|
|
993
|
+
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 4}},
|
|
994
|
+
{"type": "message_stop"}))), _Relay(None, 0))
|
|
995
|
+
assert got == {"text": "ready", "reasoning": "hm", "model": "claude-x", "finish": "stop",
|
|
996
|
+
"usage": {"input_tokens": 9, "output_tokens": 4}}, got
|
|
997
|
+
err = _anthropic_events([{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}], _Relay(None, 0))
|
|
998
|
+
assert err["error"].startswith("Error: Error code: 529 - "), err
|
|
999
|
+
assert "broke off" in _anthropic_events([{"type": "message_start", "message": {}}], _Relay(None, 0))["error"]
|
|
1000
|
+
got = _gemini_events([{"candidates": [{"content": {"parts": [{"text": "hm", "thought": True}, {"text": "rea"}]}}]},
|
|
1001
|
+
{"candidates": [{"content": {"parts": [{"text": "dy"}]}, "finishReason": "STOP"}],
|
|
1002
|
+
"modelVersion": "gemini-x", "usageMetadata": {"promptTokenCount": 3, "candidatesTokenCount": 2}}],
|
|
1003
|
+
_Relay(None, 0))
|
|
1004
|
+
assert got == {"text": "ready", "reasoning": "hm", "model": "gemini-x", "finish": "stop",
|
|
1005
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 2}}, got
|
|
1006
|
+
assert _gemini_events([{"error": {"code": 429, "message": "quota"}}], _Relay(None, 0))["error"].startswith(
|
|
1007
|
+
"Error: Error code: 429 - ")
|
|
1008
|
+
assert "broke off" in _gemini_events([{"candidates": [{"content": {"parts": [{"text": "x"}]}}]}], _Relay(None, 0))["error"]
|
|
1009
|
+
assert not _swapped("openai/gpt-oss-120b:free", "gpt-oss-120b") and _swapped("llama-3.3-70b", "llama-3.1-8b")
|
|
377
1010
|
print("ok")
|
|
378
1011
|
|
|
379
1012
|
|