@wendellresearch-ai/benchwarmers 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -8
- package/GATEWAY.md +169 -12
- package/PRIVACY.md +7 -5
- package/README.md +81 -37
- package/SECURITY.md +5 -4
- package/USAGE.md +37 -5
- package/VERSION +1 -1
- package/cli.py +1 -1
- package/gateway.py +352 -10
- package/metrics/bench_collections.py +7 -3
- package/metrics/client.py +312 -88
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +658 -95
- package/package.json +1 -1
- package/scripts/check.py +2 -0
- package/scripts/test_auth.py +2 -1
- package/scripts/test_formats.py +21 -12
- package/scripts/test_merged.py +2 -2
- package/scripts/test_native.py +188 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +2 -2
- package/serve.py +66 -8
- package/ui/bench.js +3 -1
package/metrics/client.py
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
python metrics/client.py <alias> "prompt" # one call; prints what ask_once would return
|
|
4
4
|
|
|
5
|
-
RELEASE-PLAN.md Phases 2a and 2b. A drop-in for `llm -m <alias>`,
|
|
6
|
-
Settings or
|
|
5
|
+
RELEASE-PLAN.md Phases 2a and 2b. A drop-in for `llm -m <alias>`, and the
|
|
6
|
+
default since v0.3 (`llm` stays a choice on Settings or BENCH_CLIENT=llm; see
|
|
7
|
+
chosen()), used by suite/run.py's
|
|
7
8
|
ask_once, and so the perf probe, the suite, retries and auto-retire's
|
|
8
9
|
confirmation call, and by the gateway's seats. It reads the same
|
|
9
10
|
registration (the yaml entry's model name, base, key name and headers), the
|
|
@@ -84,6 +85,21 @@ just before the call, and ChatGPT's plan is reached through the Responses API
|
|
|
84
85
|
(streamed, unstored) rather than chat completions. `llm -m` cannot do either,
|
|
85
86
|
so for those models use `python metrics/client.py <alias> "prompt"`.
|
|
86
87
|
|
|
88
|
+
NATIVE ANTHROPIC AND GEMINI: an endpoint whose `api` is `anthropic` or
|
|
89
|
+
`gemini` (the Add endpoint form) is called in that API, through here
|
|
90
|
+
whatever BENCH_CLIENT says: metrics/formats.py builds the request (system
|
|
91
|
+
text apart, the model's options it knows, images as the API's own parts),
|
|
92
|
+
the key goes as x-api-key or x-goog-api-key, and the stream is read into
|
|
93
|
+
the same result, usage and finish included. Anthropic's error events read
|
|
94
|
+
as the status that carries them (overloaded_error is 529), so the text
|
|
95
|
+
contract holds. Tool calls are not sent to them (route mode proxies chat
|
|
96
|
+
completions only).
|
|
97
|
+
|
|
98
|
+
FOR A PROXY (the gateway's route mode): `body`, `on_chunk`, `first_token`
|
|
99
|
+
and `retries` (see chat()) pass a request's own fields, relay each chunk
|
|
100
|
+
exactly, give up early on a member that sends nothing, and leave failover
|
|
101
|
+
to the caller.
|
|
102
|
+
|
|
87
103
|
RETRIES MATCH THE SDK UNDER llm: two more tries on a connection error, 408,
|
|
88
104
|
409, 429 or 5xx, waiting the Retry-After when it is 60 s or less, else about
|
|
89
105
|
0.5 then 1 s. Without them this client would surface 429s llm absorbed, and
|
|
@@ -103,11 +119,11 @@ import json
|
|
|
103
119
|
import os
|
|
104
120
|
import pathlib
|
|
105
121
|
import random
|
|
106
|
-
import shutil
|
|
107
122
|
import sys
|
|
108
123
|
import threading
|
|
109
124
|
import time
|
|
110
125
|
import urllib.error
|
|
126
|
+
import urllib.parse
|
|
111
127
|
import urllib.request
|
|
112
128
|
|
|
113
129
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
@@ -115,6 +131,7 @@ from paths import DATA # noqa: E402 -- the install's data folder (metrics/path
|
|
|
115
131
|
sys.path.insert(0, str(ROOT / "metrics"))
|
|
116
132
|
import connect # noqa: E402
|
|
117
133
|
import endpoints # noqa: E402
|
|
134
|
+
import formats # noqa: E402
|
|
118
135
|
|
|
119
136
|
OPTIONS = DATA / "config" / "model-options.json"
|
|
120
137
|
MAX_RETRIES = 2
|
|
@@ -129,10 +146,11 @@ CHOICE = DATA / "config" / "client.json" # the Settings page's choice
|
|
|
129
146
|
|
|
130
147
|
def chosen() -> tuple[str, str]:
|
|
131
148
|
"""The configured client and what set it: BENCH_CLIENT ("environment"),
|
|
132
|
-
else the Settings page's choice ("settings"), else ("default")
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
applies to the
|
|
149
|
+
else the Settings page's choice ("settings"), else ("default") the
|
|
150
|
+
built-in client, installed `llm` or not: since v0.3 the `llm` CLI is an
|
|
151
|
+
alternative chosen on Settings, no longer the default (its trial week:
|
|
152
|
+
RELEASE-PLAN Phase 2a). Read on every call, so a change applies to the
|
|
153
|
+
next one."""
|
|
136
154
|
env = (os.environ.get("BENCH_CLIENT") or "").strip().lower()
|
|
137
155
|
if env:
|
|
138
156
|
return env, "environment"
|
|
@@ -140,16 +158,17 @@ def chosen() -> tuple[str, str]:
|
|
|
140
158
|
saved = json.loads(CHOICE.read_text(encoding="utf-8")).get("client")
|
|
141
159
|
except (OSError, ValueError, AttributeError):
|
|
142
160
|
saved = None
|
|
143
|
-
return (saved, "settings") if saved in CLIENTS else ("
|
|
161
|
+
return (saved, "settings") if saved in CLIENTS else ("builtin", "default")
|
|
144
162
|
|
|
145
163
|
|
|
146
164
|
def backend(alias: str = "") -> str:
|
|
147
165
|
"""`llm` or `builtin`, as chosen(). A model on an endpoint connected by
|
|
148
|
-
sign-in whose token renews, or that speaks
|
|
149
|
-
(ChatGPT's plan
|
|
166
|
+
sign-in whose token renews, or that speaks another API than chat
|
|
167
|
+
completions (ChatGPT's plan, native Anthropic or Gemini), is always
|
|
168
|
+
`builtin`: llm can do neither."""
|
|
150
169
|
if alias:
|
|
151
170
|
entry = endpoints.entry_of(alias)
|
|
152
|
-
if entry.get("api")
|
|
171
|
+
if (entry.get("api") or "chat") != "chat" or (entry.get("connect") or {}).get("refreshes"):
|
|
153
172
|
return "builtin"
|
|
154
173
|
return chosen()[0]
|
|
155
174
|
|
|
@@ -225,15 +244,17 @@ def _wait_at_most(response, deadline: float) -> None:
|
|
|
225
244
|
pass
|
|
226
245
|
|
|
227
246
|
|
|
228
|
-
def _chunks(response, deadline: float):
|
|
247
|
+
def _chunks(response, deadline: float, relay: _Relay | None = None):
|
|
229
248
|
"""The body in pieces as they arrive. Raises _Late past the deadline (or
|
|
230
|
-
when STOP is set), ValueError past
|
|
231
|
-
long-lived process, and _Broken if the
|
|
249
|
+
the relay's first-piece cutoff, or when STOP is set), ValueError past
|
|
250
|
+
MAX_REPLY, since the gateway is a long-lived process, and _Broken if the
|
|
251
|
+
connection drops part way."""
|
|
232
252
|
size = 0
|
|
233
253
|
while True:
|
|
234
|
-
|
|
254
|
+
limit = relay.cutoff(deadline) if relay else deadline
|
|
255
|
+
if STOP.is_set() or time.monotonic() > limit:
|
|
235
256
|
raise _Late
|
|
236
|
-
_wait_at_most(response,
|
|
257
|
+
_wait_at_most(response, limit)
|
|
237
258
|
try:
|
|
238
259
|
chunk = response.read1(65536)
|
|
239
260
|
except TimeoutError:
|
|
@@ -293,26 +314,46 @@ def _events(lines):
|
|
|
293
314
|
|
|
294
315
|
|
|
295
316
|
class _Relay:
|
|
296
|
-
"""on_delta, kept from breaking the call: one that raises is
|
|
317
|
+
"""on_delta and on_chunk, kept from breaking the call: one that raises is
|
|
318
|
+
dropped. `first_by`: when the first piece must have come by (monotonic),
|
|
319
|
+
for a caller that would rather try another model than wait (route mode)."""
|
|
297
320
|
|
|
298
|
-
def __init__(self, on_delta, started: float):
|
|
321
|
+
def __init__(self, on_delta, started: float, on_chunk=None, first_by: float | None = None):
|
|
299
322
|
self.fn, self.started, self.first = on_delta, started, None
|
|
323
|
+
self.chunk_fn, self.first_by = on_chunk, first_by
|
|
324
|
+
|
|
325
|
+
def mark(self) -> None:
|
|
326
|
+
"""The first piece (text, reasoning or a tool call) has arrived."""
|
|
327
|
+
if self.first is None:
|
|
328
|
+
self.first = round(time.monotonic() - self.started, 2)
|
|
329
|
+
|
|
330
|
+
def cutoff(self, deadline: float) -> float:
|
|
331
|
+
"""What a wait may last until: the deadline, or sooner while the first piece is still awaited."""
|
|
332
|
+
return min(deadline, self.first_by) if self.first is None and self.first_by is not None else deadline
|
|
300
333
|
|
|
301
334
|
def __call__(self, kind: str, piece: str) -> None:
|
|
302
335
|
if not piece:
|
|
303
336
|
return
|
|
304
|
-
|
|
305
|
-
self.first = round(time.monotonic() - self.started, 2)
|
|
337
|
+
self.mark()
|
|
306
338
|
if self.fn is not None:
|
|
307
339
|
try:
|
|
308
340
|
self.fn(kind, piece)
|
|
309
341
|
except Exception: # noqa: BLE001
|
|
310
342
|
self.fn = None
|
|
311
343
|
|
|
344
|
+
def chunk(self, chunk: dict) -> None:
|
|
345
|
+
if self.chunk_fn is not None:
|
|
346
|
+
try:
|
|
347
|
+
self.chunk_fn(chunk)
|
|
348
|
+
except Exception: # noqa: BLE001
|
|
349
|
+
self.chunk_fn = None
|
|
350
|
+
|
|
312
351
|
|
|
313
352
|
def _chat_stream(events, relay: _Relay) -> dict:
|
|
314
|
-
"""A streamed chat completion as chat()'s fields.
|
|
353
|
+
"""A streamed chat completion as chat()'s fields. Each chunk that is not
|
|
354
|
+
an error goes to relay.chunk as it came, for a proxy (route mode)."""
|
|
315
355
|
text, reasoning, model, usage, finish, seen, done = [], [], "", {}, "", False, False
|
|
356
|
+
calls: set = set()
|
|
316
357
|
for ev in events:
|
|
317
358
|
if ev == "[DONE]":
|
|
318
359
|
done = True
|
|
@@ -331,6 +372,7 @@ def _chat_stream(events, relay: _Relay) -> dict:
|
|
|
331
372
|
if isinstance(ev.get("usage"), dict) and ev["usage"]:
|
|
332
373
|
usage = ev["usage"]
|
|
333
374
|
if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
|
|
375
|
+
relay.chunk(ev)
|
|
334
376
|
continue
|
|
335
377
|
seen = True
|
|
336
378
|
delta = choices[0].get("delta") or choices[0].get("message") or {}
|
|
@@ -341,6 +383,11 @@ def _chat_stream(events, relay: _Relay) -> dict:
|
|
|
341
383
|
if piece := _text(delta.get("content")):
|
|
342
384
|
text.append(piece)
|
|
343
385
|
relay("text", piece)
|
|
386
|
+
for tc in delta.get("tool_calls") or ():
|
|
387
|
+
if isinstance(tc, dict):
|
|
388
|
+
calls.add(tc["index"] if tc.get("index") is not None else tc.get("id"))
|
|
389
|
+
relay.mark()
|
|
390
|
+
relay.chunk(ev)
|
|
344
391
|
finish = choices[0].get("finish_reason") or finish
|
|
345
392
|
if not seen:
|
|
346
393
|
return {"error": "Error: client failed: the reply had no choices: the stream sent none"}
|
|
@@ -348,11 +395,12 @@ def _chat_stream(events, relay: _Relay) -> dict:
|
|
|
348
395
|
return {"error": f"Error: client failed: the stream broke off before it finished"
|
|
349
396
|
f" ({len(''.join(text))} characters in)"}
|
|
350
397
|
return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model, "usage": usage,
|
|
351
|
-
"finish": finish}
|
|
398
|
+
"finish": finish, "tool_calls": len(calls)}
|
|
352
399
|
|
|
353
400
|
|
|
354
|
-
def _chat_json(raw: bytes) -> dict:
|
|
355
|
-
"""A plain JSON chat completion (an upstream that does not stream) as
|
|
401
|
+
def _chat_json(raw: bytes, relay: _Relay | None = None) -> dict:
|
|
402
|
+
"""A plain JSON chat completion (an upstream that does not stream) as
|
|
403
|
+
chat()'s fields; to relay.chunk as the one chunk it would have streamed."""
|
|
356
404
|
try:
|
|
357
405
|
data = json.loads(raw or b"{}")
|
|
358
406
|
except ValueError:
|
|
@@ -369,36 +417,132 @@ def _chat_json(raw: bytes) -> dict:
|
|
|
369
417
|
if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
|
|
370
418
|
return {"error": f"Error: client failed: the reply had no choices: {str(data)[:1000]}"}
|
|
371
419
|
message = choices[0].get("message") or {}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
420
|
+
calls = [tc for tc in message.get("tool_calls") or () if isinstance(tc, dict)]
|
|
421
|
+
out = {"text": _text(message.get("content")),
|
|
422
|
+
"reasoning": _text(message.get("reasoning_content") or message.get("reasoning")),
|
|
423
|
+
"model": data.get("model") or "", "usage": data.get("usage") or {},
|
|
424
|
+
"finish": choices[0].get("finish_reason") or "", "tool_calls": len(calls)}
|
|
425
|
+
if relay is not None:
|
|
426
|
+
if out["text"] or out["reasoning"] or calls:
|
|
427
|
+
relay.mark()
|
|
428
|
+
delta = {"role": "assistant", **{k: message[k] for k in ("content", "reasoning_content", "reasoning")
|
|
429
|
+
if message.get(k)}}
|
|
430
|
+
if calls:
|
|
431
|
+
delta["tool_calls"] = [{"index": i, **tc} for i, tc in enumerate(calls)]
|
|
432
|
+
relay.chunk({"id": data.get("id") or "", "object": "chat.completion.chunk", "created": data.get("created"),
|
|
433
|
+
"model": out["model"], "usage": out["usage"] or None,
|
|
434
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": out["finish"] or "stop"}]})
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _reply(response, deadline: float, api: str, relay: _Relay) -> dict:
|
|
439
|
+
"""The reply's fields, read as it arrives, in the endpoint's API (`api`:
|
|
440
|
+
chat, responses, anthropic or gemini). Raises _Late past the deadline."""
|
|
380
441
|
ctype = (response.headers.get("content-type") or "").lower()
|
|
442
|
+
stream = {"responses": _responses_events, "anthropic": _anthropic_events, "gemini": _gemini_events}
|
|
381
443
|
try:
|
|
382
|
-
if responses:
|
|
383
|
-
return
|
|
444
|
+
if api == "responses" or (api in stream and "event-stream" in ctype):
|
|
445
|
+
return stream[api](_events(_lines(_chunks(response, deadline, relay))), relay)
|
|
384
446
|
if "event-stream" in ctype:
|
|
385
|
-
return _chat_stream(_events(_lines(_chunks(response, deadline))), relay)
|
|
386
|
-
raw = b"".join(_chunks(response, deadline))
|
|
447
|
+
return _chat_stream(_events(_lines(_chunks(response, deadline, relay))), relay)
|
|
448
|
+
raw = b"".join(_chunks(response, deadline, relay))
|
|
387
449
|
except _Broken as e:
|
|
388
450
|
# Not retried: what arrived may already have been relayed.
|
|
389
451
|
return {"error": f"Error: client failed: the reply broke off part way ({e})"}
|
|
452
|
+
if api in stream: # a native API answering with one JSON body
|
|
453
|
+
try:
|
|
454
|
+
return stream[api]([json.loads(raw or b"{}")], relay)
|
|
455
|
+
except ValueError:
|
|
456
|
+
return {"error": "Error: client failed: the reply was not JSON: " + raw[:1000].decode("utf-8", "replace")}
|
|
390
457
|
if raw.lstrip().startswith(b"data:"): # a stream sent without its content type
|
|
391
458
|
return _chat_stream(_events(_lines([raw])), relay)
|
|
392
|
-
return _chat_json(raw)
|
|
459
|
+
return _chat_json(raw, relay)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# Anthropic's error types as the HTTP statuses that carry them, so an error
|
|
463
|
+
# sent mid-stream reads with the same markers (429, 529) as one sent as a status.
|
|
464
|
+
ANTHROPIC_STATUS = {"invalid_request_error": 400, "authentication_error": 401, "permission_error": 403,
|
|
465
|
+
"not_found_error": 404, "request_too_large": 413, "rate_limit_error": 429,
|
|
466
|
+
"api_error": 500, "overloaded_error": 529}
|
|
467
|
+
ANTHROPIC_FINISH = {"end_turn": "stop", "stop_sequence": "stop", "max_tokens": "length", "tool_use": "tool_calls"}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _anthropic_events(events, relay: _Relay) -> dict:
|
|
471
|
+
"""A native Anthropic Messages stream (or one whole reply) as chat()'s
|
|
472
|
+
fields. Finished only once message_stop (or a whole message) arrives."""
|
|
473
|
+
text, reasoning, model, usage, finish, done = [], [], "", {}, "", False
|
|
474
|
+
for ev in events:
|
|
475
|
+
if not isinstance(ev, dict):
|
|
476
|
+
continue
|
|
477
|
+
kind = ev.get("type")
|
|
478
|
+
if kind == "error":
|
|
479
|
+
err = ev.get("error") if isinstance(ev.get("error"), dict) else {}
|
|
480
|
+
return {"error": f"Error: Error code: {ANTHROPIC_STATUS.get(err.get('type'), err.get('type') or 'stream')} - {ev}"}
|
|
481
|
+
if kind == "message": # a whole reply, not a stream
|
|
482
|
+
text.append(formats.anthropic_text(ev))
|
|
483
|
+
relay("text", text[-1])
|
|
484
|
+
model, usage, finish, done = ev.get("model") or "", ev.get("usage") or {}, ev.get("stop_reason") or "", True
|
|
485
|
+
elif kind == "message_start":
|
|
486
|
+
message = ev.get("message") or {}
|
|
487
|
+
model, usage = message.get("model") or "", dict(message.get("usage") or {})
|
|
488
|
+
elif kind == "content_block_delta":
|
|
489
|
+
delta = ev.get("delta") or {}
|
|
490
|
+
if delta.get("type") == "text_delta":
|
|
491
|
+
text.append(delta.get("text") or "")
|
|
492
|
+
relay("text", delta.get("text") or "")
|
|
493
|
+
elif delta.get("type") == "thinking_delta":
|
|
494
|
+
reasoning.append(delta.get("thinking") or "")
|
|
495
|
+
relay("reasoning", delta.get("thinking") or "")
|
|
496
|
+
elif kind == "message_delta":
|
|
497
|
+
finish = (ev.get("delta") or {}).get("stop_reason") or finish
|
|
498
|
+
usage.update({k: v for k, v in (ev.get("usage") or {}).items() if v is not None})
|
|
499
|
+
elif kind == "message_stop":
|
|
500
|
+
done = True
|
|
501
|
+
if not done:
|
|
502
|
+
return {"error": f"Error: client failed: the stream broke off before it finished ({len(''.join(text))} characters in)"}
|
|
503
|
+
return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model, "usage": usage,
|
|
504
|
+
"finish": ANTHROPIC_FINISH.get(finish, finish)}
|
|
393
505
|
|
|
394
506
|
|
|
395
|
-
def
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
507
|
+
def _gemini_events(events, relay: _Relay) -> dict:
|
|
508
|
+
"""A native Gemini generateContent stream (or one whole reply) as chat()'s
|
|
509
|
+
fields. A stream has no end marker: it is finished once a candidate says
|
|
510
|
+
why it stopped."""
|
|
511
|
+
text, reasoning, model, usage, finish = [], [], "", {}, ""
|
|
512
|
+
for ev in events:
|
|
513
|
+
if not isinstance(ev, dict):
|
|
514
|
+
continue
|
|
515
|
+
if ev.get("error"):
|
|
516
|
+
err = ev["error"] if isinstance(ev["error"], dict) else {}
|
|
517
|
+
return {"error": f"Error: Error code: {err.get('code') or 'stream'} - {ev}"}
|
|
518
|
+
if (blocked := (ev.get("promptFeedback") or {}).get("blockReason")):
|
|
519
|
+
return {"error": f"Error: client failed: the prompt was blocked ({blocked}): {ev}"}
|
|
520
|
+
model = ev.get("modelVersion") or model
|
|
521
|
+
if (meta := ev.get("usageMetadata")):
|
|
522
|
+
usage = {"prompt_tokens": meta.get("promptTokenCount"), "completion_tokens": meta.get("candidatesTokenCount"),
|
|
523
|
+
**({"completion_tokens_details": {"reasoning_tokens": meta["thoughtsTokenCount"]}}
|
|
524
|
+
if meta.get("thoughtsTokenCount") else {})}
|
|
525
|
+
candidate = (ev.get("candidates") or [{}])[0]
|
|
526
|
+
for part in (candidate.get("content") or {}).get("parts") or ():
|
|
527
|
+
if part.get("text"):
|
|
528
|
+
(reasoning if part.get("thought") else text).append(part["text"])
|
|
529
|
+
relay("reasoning" if part.get("thought") else "text", part["text"])
|
|
530
|
+
finish = candidate.get("finishReason") or finish
|
|
531
|
+
if not finish:
|
|
532
|
+
return {"error": f"Error: client failed: the stream broke off before it finished ({len(''.join(text))} characters in)"}
|
|
533
|
+
return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model,
|
|
534
|
+
"usage": {k: v for k, v in usage.items() if v is not None},
|
|
535
|
+
"finish": {"STOP": "stop", "MAX_TOKENS": "length"}.get(finish, finish.lower())}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _exchange(url: str, payload: dict, headers: dict, deadline: float, api: str,
|
|
539
|
+
relay: _Relay, observe, retries: int = MAX_RETRIES) -> dict:
|
|
540
|
+
"""Send the call with the SDK's retries (`retries` more tries); chat()'s
|
|
541
|
+
fields plus `attempts`. `observe(status, headers, body)` sees every
|
|
542
|
+
response, for quota records."""
|
|
399
543
|
attempts, attempt, fell_back = 0, 0, False
|
|
400
544
|
while True:
|
|
401
|
-
left = deadline - time.monotonic()
|
|
545
|
+
left = relay.cutoff(deadline) - time.monotonic()
|
|
402
546
|
if left <= 0:
|
|
403
547
|
return {"timed_out": True, "attempts": attempts}
|
|
404
548
|
request = urllib.request.Request(url, json.dumps(payload).encode(), headers)
|
|
@@ -408,7 +552,7 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
|
|
|
408
552
|
with _OPENER.open(request, timeout=left) as r:
|
|
409
553
|
status = r.status
|
|
410
554
|
observe(status, r.headers, None)
|
|
411
|
-
got = _reply(r, deadline,
|
|
555
|
+
got = _reply(r, deadline, api, relay)
|
|
412
556
|
return {"status": status, "attempts": attempts, **got}
|
|
413
557
|
except _Late:
|
|
414
558
|
return {"status": status, "timed_out": True, "attempts": attempts}
|
|
@@ -424,7 +568,7 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
|
|
|
424
568
|
observe(e.code, e.headers, shown)
|
|
425
569
|
# An upstream that does not know stream_options, or will not stream:
|
|
426
570
|
# the same call once more without them, not counted as a retry.
|
|
427
|
-
if (
|
|
571
|
+
if (api == "chat" and not fell_back and e.code in (400, 422) and payload.get("stream")
|
|
428
572
|
and "stream" in text.lower()):
|
|
429
573
|
drop = ("stream_options",) if "stream_options" in text.lower() else ("stream", "stream_options")
|
|
430
574
|
payload = {k: v for k, v in payload.items() if k not in drop}
|
|
@@ -434,10 +578,10 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
|
|
|
434
578
|
err = f"Error: Error code: {e.code}" + (f" - {shown}" if text else "") \
|
|
435
579
|
+ (f" Retry-After: {e.headers.get('retry-after')}" if e.headers.get("retry-after") else "") \
|
|
436
580
|
+ (f" (redirect to {e.headers.get('location')} not followed)" if 300 <= e.code < 400 else "")
|
|
437
|
-
if not (e.code in RETRY_STATUS or e.code >= 500) or attempt
|
|
581
|
+
if not (e.code in RETRY_STATUS or e.code >= 500) or attempt >= retries:
|
|
438
582
|
return {"status": e.code, "error": err, "attempts": attempts}
|
|
439
583
|
wait = wait or _backoff(attempt)
|
|
440
|
-
if time.monotonic() + wait >= deadline:
|
|
584
|
+
if time.monotonic() + wait >= relay.cutoff(deadline):
|
|
441
585
|
return {"status": e.code, "error": err, "attempts": attempts}
|
|
442
586
|
time.sleep(wait)
|
|
443
587
|
attempt += 1
|
|
@@ -445,11 +589,11 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
|
|
|
445
589
|
reason = getattr(e, "reason", e)
|
|
446
590
|
# The deadline itself, or the OS giving up on a connection early
|
|
447
591
|
# (a dropped SYN), which is a connection error and is retried.
|
|
448
|
-
if isinstance(reason, TimeoutError) and time.monotonic() >= deadline - 0.5:
|
|
592
|
+
if isinstance(reason, TimeoutError) and time.monotonic() >= relay.cutoff(deadline) - 0.5:
|
|
449
593
|
return {"timed_out": True, "attempts": attempts}
|
|
450
594
|
err = f"Error: client failed: Connection error. ({reason})"
|
|
451
595
|
wait = _backoff(attempt)
|
|
452
|
-
if attempt
|
|
596
|
+
if attempt >= retries or time.monotonic() + wait >= relay.cutoff(deadline):
|
|
453
597
|
return {"error": err, "attempts": attempts}
|
|
454
598
|
time.sleep(wait)
|
|
455
599
|
attempt += 1
|
|
@@ -520,29 +664,81 @@ def _caller() -> str:
|
|
|
520
664
|
|
|
521
665
|
|
|
522
666
|
def chat(alias: str, prompt: str, system: str = "", timeout: float = 420,
|
|
523
|
-
messages: list[dict] | None = None, on_delta=None, purpose: str = ""
|
|
667
|
+
messages: list[dict] | None = None, on_delta=None, purpose: str = "",
|
|
668
|
+
body: dict | None = None, on_chunk=None, first_token: float | None = None,
|
|
669
|
+
retries: int | None = None) -> dict:
|
|
524
670
|
"""One chat completion. Returns {text, reasoning, error, timed_out, status,
|
|
525
|
-
model (as served), usage, finish,
|
|
526
|
-
`budget_retry` when an empty answer was tried
|
|
527
|
-
success, else the contract text. `messages`, when
|
|
528
|
-
are instead of system + prompt: the gateway's
|
|
529
|
-
which `llm -m` cannot send. `on_delta(kind,
|
|
530
|
-
("text") and the reasoning ("reasoning") as they
|
|
531
|
-
into the ledger line beside the calling script's name.
|
|
671
|
+
model (as served), usage, finish, tool_calls (how many), first_token_s,
|
|
672
|
+
attempts, seconds}, and `budget_retry` when an empty answer was tried
|
|
673
|
+
again; `error` is "" on success, else the contract text. `messages`, when
|
|
674
|
+
given, are sent as they are instead of system + prompt: the gateway's
|
|
675
|
+
requests that carry images, which `llm -m` cannot send. `on_delta(kind,
|
|
676
|
+
text)` hears the answer ("text") and the reasoning ("reasoning") as they
|
|
677
|
+
arrive. `purpose` goes into the ledger line beside the calling script's name.
|
|
678
|
+
|
|
679
|
+
For a proxy (the gateway's route mode), chat completions only: `body`,
|
|
680
|
+
the request's other fields (tools, tool_choice, response_format,
|
|
681
|
+
temperature, ...), which win over the model's own options; `on_chunk`,
|
|
682
|
+
which hears each stream chunk exactly as it came (or the one a whole
|
|
683
|
+
reply would have been); `first_token`, seconds to wait for the first
|
|
684
|
+
piece (text, reasoning or a tool call) before giving up as timed out;
|
|
685
|
+
`retries`, how many more tries a 429, 5xx or connection error gets (the
|
|
686
|
+
SDK's two by default). An empty answer is not tried again: it is relayed."""
|
|
532
687
|
started, t0 = time.monotonic(), time.time()
|
|
533
688
|
deadline = started + timeout
|
|
534
689
|
res = {"text": "", "reasoning": "", "error": "", "timed_out": False, "status": 0,
|
|
535
|
-
"model": "", "usage": {}, "finish": "", "first_token_s": None, "attempts": 0}
|
|
690
|
+
"model": "", "usage": {}, "finish": "", "tool_calls": 0, "first_token_s": None, "attempts": 0}
|
|
536
691
|
ctx = {"connection": "", "endpoint": "", "ticket": None, "requested": "", "refused": False}
|
|
692
|
+
relay = _Relay(on_delta, started, on_chunk, started + first_token if first_token else None)
|
|
537
693
|
try:
|
|
538
|
-
_call(alias, prompt, system, messages, deadline,
|
|
694
|
+
_call(alias, prompt, system, messages, deadline, relay, res, ctx, body,
|
|
695
|
+
MAX_RETRIES if retries is None else retries)
|
|
539
696
|
finally:
|
|
540
697
|
res["seconds"] = round(time.monotonic() - started, 1)
|
|
541
698
|
_record(alias, res, ctx, t0, purpose)
|
|
542
699
|
return res
|
|
543
700
|
|
|
544
701
|
|
|
545
|
-
|
|
702
|
+
# Anthropic's API version header: required on every call, and the one its
|
|
703
|
+
# Messages API has been served under since it launched.
|
|
704
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _request(api: str, base: str, model: str, alias: str, prompt: str, system: str,
|
|
708
|
+
messages: list[dict] | None, body: dict | None) -> tuple[str, dict]:
|
|
709
|
+
"""(url, payload) for one call in the endpoint's API. Raises ValueError
|
|
710
|
+
for what that API cannot be sent from here."""
|
|
711
|
+
if body is not None and api != "chat":
|
|
712
|
+
raise ValueError(f"a request's own fields (tools and the rest) go to chat completions only, not the {api} API")
|
|
713
|
+
if api == "responses":
|
|
714
|
+
# ChatGPT's plan (metrics/connect.py): streamed, unstored, no system
|
|
715
|
+
# message items, no sampling or length options.
|
|
716
|
+
if messages is not None:
|
|
717
|
+
# ponytail: chat parts are not Responses input items; converting
|
|
718
|
+
# them (input_text, input_image) waits for a caller that needs it.
|
|
719
|
+
raise ValueError("given messages are sent to chat completions only, not the Responses API")
|
|
720
|
+
return base + "/responses", {"model": model, "input": [{"role": "user", "content": prompt}],
|
|
721
|
+
"stream": True, "store": False, **({"instructions": system} if system else {})}
|
|
722
|
+
if messages is None:
|
|
723
|
+
messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
|
|
724
|
+
opts = _options(alias)
|
|
725
|
+
if api == "anthropic":
|
|
726
|
+
# formats.py builds the native request; the model's options that it
|
|
727
|
+
# knows (max_tokens, temperature, top_p, stop) go with it.
|
|
728
|
+
return base + "/messages", formats.anthropic_request(messages, model, opts, stream=True)
|
|
729
|
+
if api == "gemini":
|
|
730
|
+
# The base is the API's version root (.../v1beta), as its model list is under it.
|
|
731
|
+
return (f"{base}/models/{urllib.parse.quote(model.removeprefix('models/'), safe='-._~')}"
|
|
732
|
+
":streamGenerateContent?alt=sse", formats.gemini_request(messages, opts))
|
|
733
|
+
payload = {"model": model, "messages": messages, **opts}
|
|
734
|
+
if body:
|
|
735
|
+
if "max_completion_tokens" in body:
|
|
736
|
+
payload.pop("max_tokens", None) # both at once are refused by some APIs
|
|
737
|
+
payload.update({k: v for k, v in body.items() if k not in ("model", "messages", "stream", "stream_options")})
|
|
738
|
+
return base + "/chat/completions", {**payload, "stream": True, "stream_options": {"include_usage": True}}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _call(alias, prompt, system, messages, deadline, relay, res, ctx, body, retries) -> None:
|
|
546
742
|
reg = endpoints.registrations().get(alias)
|
|
547
743
|
if reg is None:
|
|
548
744
|
res["error"] = f"Error: client failed: no model is registered as {alias!r}"
|
|
@@ -553,27 +749,16 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
|
|
|
553
749
|
res["error"] = f"Error: client failed: endpoint base must be http(s), not {base!r}"
|
|
554
750
|
return
|
|
555
751
|
model = ctx["requested"] = reg.get("model_name") or alias
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
res["error"] = ("Error: client failed: given messages are sent to chat completions only,"
|
|
563
|
-
" not the Responses API")
|
|
564
|
-
return
|
|
565
|
-
url = base + "/responses"
|
|
566
|
-
payload = {"model": model, "input": [{"role": "user", "content": prompt}],
|
|
567
|
-
"stream": True, "store": False, **({"instructions": system} if system else {})}
|
|
568
|
-
else:
|
|
569
|
-
url = base + "/chat/completions"
|
|
570
|
-
if messages is None:
|
|
571
|
-
messages = ([{"role": "system", "content": system}] if system else []) \
|
|
572
|
-
+ [{"role": "user", "content": prompt}]
|
|
573
|
-
payload = {"model": model, "messages": messages, **_options(alias),
|
|
574
|
-
"stream": True, "stream_options": {"include_usage": True}}
|
|
752
|
+
api = endpoints.entry_of(alias).get("api") or "chat"
|
|
753
|
+
try:
|
|
754
|
+
url, payload = _request(api, base, model, alias, prompt, system, messages, body)
|
|
755
|
+
except ValueError as e: # formats.Unsupported is one too
|
|
756
|
+
res["error"] = f"Error: client failed: {e}"
|
|
757
|
+
return
|
|
575
758
|
headers = {"Content-Type": "application/json", "User-Agent": "benchwarmers",
|
|
576
759
|
**reg.get("headers", {})}
|
|
760
|
+
if api == "anthropic":
|
|
761
|
+
headers["anthropic-version"] = ANTHROPIC_VERSION
|
|
577
762
|
if name := reg.get("api_key_name", ""):
|
|
578
763
|
if why := connect.fresh(name): # a signed-in token past renewal
|
|
579
764
|
res["error"] = f"Error: client failed: {why}"
|
|
@@ -583,7 +768,7 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
|
|
|
583
768
|
if not (key := endpoints.keys().get(name, "")):
|
|
584
769
|
res["error"] = f"Error: client failed: no key named {name!r} is set (llm keys set {name})"
|
|
585
770
|
return
|
|
586
|
-
headers
|
|
771
|
+
headers.update(endpoints.auth_headers(api, key))
|
|
587
772
|
|
|
588
773
|
try:
|
|
589
774
|
if str(ROOT / "suite") not in sys.path: # route reads the registry through suite/run.py
|
|
@@ -598,8 +783,12 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
|
|
|
598
783
|
return
|
|
599
784
|
try:
|
|
600
785
|
import pacing
|
|
601
|
-
|
|
602
|
-
|
|
786
|
+
# A proxy that would rather try another model waits for room no
|
|
787
|
+
# longer than for a first piece. ponytail: the in-flight slot then
|
|
788
|
+
# expires that much sooner too; per-call expiry if it ever matters.
|
|
789
|
+
ctx["ticket"], why = pacing.admit(alias, ctx["connection"],
|
|
790
|
+
_estimate(prompt, system, messages) + len(json.dumps(body or "")) // 4,
|
|
791
|
+
relay.cutoff(deadline) - time.monotonic())
|
|
603
792
|
except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
|
|
604
793
|
why = ""
|
|
605
794
|
if why:
|
|
@@ -616,12 +805,13 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
|
|
|
616
805
|
except Exception: # noqa: BLE001
|
|
617
806
|
pass
|
|
618
807
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (
|
|
808
|
+
got = _exchange(url, payload, headers, deadline, api, relay, observe, retries)
|
|
809
|
+
# Not for a proxy: what it relayed already went to its caller.
|
|
810
|
+
if (api in ("chat", "anthropic") and relay.chunk_fn is None and not got.get("error")
|
|
811
|
+
and not got.get("timed_out") and got.get("text") == "" and not got.get("tool_calls")
|
|
622
812
|
and got.get("finish") == "length" and (grow := _budget(payload, got.get("usage") or {}, alias))):
|
|
623
813
|
key, used, bigger = grow
|
|
624
|
-
again = _exchange(url, {**payload, key: bigger}, headers, deadline,
|
|
814
|
+
again = _exchange(url, {**payload, key: bigger}, headers, deadline, api, relay, observe, retries)
|
|
625
815
|
attempts = got.get("attempts", 0) + again.get("attempts", 0)
|
|
626
816
|
if again.get("error") or again.get("timed_out"):
|
|
627
817
|
# The empty answer stands: the retry's failure (a 400 for a budget
|
|
@@ -650,7 +840,7 @@ def _record(alias: str, res: dict, ctx: dict, t0: float, purpose: str) -> None:
|
|
|
650
840
|
print(f"note: {alias} asked for {ctx['requested']} and was served {res['model']}"
|
|
651
841
|
" (a silent swap?)", file=sys.stderr)
|
|
652
842
|
status = ("refused" if ctx["refused"] else "timeout" if res["timed_out"] else
|
|
653
|
-
"error" if res["error"] else "ok" if res["text"] else "empty")
|
|
843
|
+
"error" if res["error"] else "ok" if res["text"] or res.get("tool_calls") else "empty")
|
|
654
844
|
import ledger
|
|
655
845
|
t = ctx.get("sent") or t0
|
|
656
846
|
row = {"t": round(t, 3), "at": dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds"),
|
|
@@ -762,7 +952,7 @@ def _selftest() -> None:
|
|
|
762
952
|
{"choices": [{"delta": {"content": "rea"}}]},
|
|
763
953
|
{"choices": [{"delta": {"content": [{"type": "text", "text": "dy"}]}, "finish_reason": "stop"}]},
|
|
764
954
|
{"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 5}}, "[DONE]"))), relay)
|
|
765
|
-
assert got == {"text": "ready", "reasoning": "hmm so", "model": "m-2", "finish": "stop",
|
|
955
|
+
assert got == {"text": "ready", "reasoning": "hmm so", "model": "m-2", "finish": "stop", "tool_calls": 0,
|
|
766
956
|
"usage": {"prompt_tokens": 3, "completion_tokens": 5}}, got
|
|
767
957
|
assert heard == [("reasoning", "hmm "), ("reasoning", "so"), ("text", "rea"), ("text", "dy")], heard
|
|
768
958
|
assert relay.first is not None
|
|
@@ -782,7 +972,41 @@ def _selftest() -> None:
|
|
|
782
972
|
bad("text", "x")
|
|
783
973
|
assert bad.fn is None
|
|
784
974
|
assert _chat_json(b'{"choices":[{"message":{"content":"","reasoning":"r"},"finish_reason":"length"}]}') \
|
|
785
|
-
== {"text": "", "reasoning": "r", "model": "", "usage": {}, "finish": "length"}
|
|
975
|
+
== {"text": "", "reasoning": "r", "model": "", "usage": {}, "finish": "length", "tool_calls": 0}
|
|
976
|
+
# A proxy hears every chunk as it came, and a whole reply as the one chunk it would have streamed.
|
|
977
|
+
seen: list = []
|
|
978
|
+
relay = _Relay(None, 0, seen.append, first_by=0)
|
|
979
|
+
assert relay.cutoff(10) == 0 and _Relay(None, 0).cutoff(10) == 10 # awaiting the first piece
|
|
980
|
+
call = {"index": 0, "id": "c1", "function": {"name": "f", "arguments": "{}"}}
|
|
981
|
+
got = _chat_stream(_events(_lines(chunks({"choices": [{"delta": {"tool_calls": [call]}}]},
|
|
982
|
+
{"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}, "[DONE]"))), relay)
|
|
983
|
+
assert got["tool_calls"] == 1 and len(seen) == 2 and seen[0]["choices"][0]["delta"]["tool_calls"] == [call]
|
|
984
|
+
assert relay.cutoff(10) == 10 # a tool call is a first piece
|
|
985
|
+
seen.clear()
|
|
986
|
+
_chat_json(b'{"id":"x","choices":[{"message":{"content":null,"tool_calls":[{"id":"c1","function":{}}]},'
|
|
987
|
+
b'"finish_reason":"tool_calls"}]}', _Relay(None, 0, seen.append))
|
|
988
|
+
assert seen[0]["choices"][0]["delta"]["tool_calls"] == [{"index": 0, "id": "c1", "function": {}}], seen
|
|
989
|
+
# Native streams: Anthropic Messages and Gemini, read into the same fields.
|
|
990
|
+
got = _anthropic_events(_events(_lines(chunks(
|
|
991
|
+
{"type": "message_start", "message": {"model": "claude-x", "usage": {"input_tokens": 9}}},
|
|
992
|
+
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "hm"}},
|
|
993
|
+
{"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "ready"}},
|
|
994
|
+
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 4}},
|
|
995
|
+
{"type": "message_stop"}))), _Relay(None, 0))
|
|
996
|
+
assert got == {"text": "ready", "reasoning": "hm", "model": "claude-x", "finish": "stop",
|
|
997
|
+
"usage": {"input_tokens": 9, "output_tokens": 4}}, got
|
|
998
|
+
err = _anthropic_events([{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}], _Relay(None, 0))
|
|
999
|
+
assert err["error"].startswith("Error: Error code: 529 - "), err
|
|
1000
|
+
assert "broke off" in _anthropic_events([{"type": "message_start", "message": {}}], _Relay(None, 0))["error"]
|
|
1001
|
+
got = _gemini_events([{"candidates": [{"content": {"parts": [{"text": "hm", "thought": True}, {"text": "rea"}]}}]},
|
|
1002
|
+
{"candidates": [{"content": {"parts": [{"text": "dy"}]}, "finishReason": "STOP"}],
|
|
1003
|
+
"modelVersion": "gemini-x", "usageMetadata": {"promptTokenCount": 3, "candidatesTokenCount": 2}}],
|
|
1004
|
+
_Relay(None, 0))
|
|
1005
|
+
assert got == {"text": "ready", "reasoning": "hm", "model": "gemini-x", "finish": "stop",
|
|
1006
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 2}}, got
|
|
1007
|
+
assert _gemini_events([{"error": {"code": 429, "message": "quota"}}], _Relay(None, 0))["error"].startswith(
|
|
1008
|
+
"Error: Error code: 429 - ")
|
|
1009
|
+
assert "broke off" in _gemini_events([{"candidates": [{"content": {"parts": [{"text": "x"}]}}]}], _Relay(None, 0))["error"]
|
|
786
1010
|
assert not _swapped("openai/gpt-oss-120b:free", "gpt-oss-120b") and _swapped("llama-3.3-70b", "llama-3.1-8b")
|
|
787
1011
|
print("ok")
|
|
788
1012
|
|