@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/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 Phase 2a. A drop-in for `llm -m <alias>`, used when chosen
6
- on Settings or by BENCH_CLIENT=builtin (the default is still `llm`; see
7
- chosen()): suite/run.py's ask_once,
8
- and so the perf probe, the suite, retries and auto-retire's confirmation call,
9
- and the gateway's seats. It reads the same registration (the yaml entry's
10
- model name, base, key name and headers), the same keys.json and the same
11
- per-model options as `llm`, and it ends the "database is locked" crash that
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 only the Responses API
107
- (ChatGPT's plan), is always `builtin`: llm can do neither."""
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") == "responses" or (entry.get("connect") or {}).get("refreshes"):
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
- def _read(response, deadline: float) -> bytes | None:
167
- """The whole body, or None if the deadline passes while it arrives. Read in
168
- pieces because the socket timeout is per wait: a server trickling bytes
169
- would otherwise hold a seat past its deadline. Raises ValueError past
170
- MAX_REPLY, since the gateway is a long-lived process."""
171
- chunks, size = [], 0
172
- while chunk := response.read1(65536):
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
- chunks.append(chunk)
177
- if time.monotonic() > deadline:
178
- return None
179
- return b"".join(chunks)
268
+ yield chunk
180
269
 
181
270
 
182
- def chat(alias: str, prompt: str, system: str = "", timeout: float = 420,
183
- messages: list[dict] | None = None) -> dict:
184
- """One chat completion. Returns {text, reasoning, error, timed_out, status,
185
- model, usage, seconds}; `error` is "" on success, else the contract text.
186
- `messages`, when given, are sent as they are instead of system + prompt:
187
- the gateway's requests that carry images, which `llm -m` cannot send."""
188
- started = time.monotonic()
189
- deadline = started + timeout
190
- res = {"text": "", "reasoning": "", "error": "", "timed_out": False, "status": 0,
191
- "model": "", "usage": {}}
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
- reg = endpoints.registrations().get(alias)
198
- if reg is None:
199
- return done(error=f"Error: client failed: no model is registered as {alias!r}")
200
- base = (reg.get("api_base") or "").rstrip("/")
201
- # urllib would also open file:// and ftp:// URLs given as a base.
202
- if not base.startswith(("http://", "https://")):
203
- return done(error=f"Error: client failed: endpoint base must be http(s), not {base!r}")
204
- model = reg.get("model_name") or alias
205
- if responses := endpoints.entry_of(alias).get("api") == "responses":
206
- # ChatGPT's plan (metrics/connect.py): streamed, unstored, no system
207
- # message items, no sampling or length options.
208
- if messages is not None:
209
- # ponytail: chat parts are not Responses input items; converting
210
- # them (input_text, input_image) waits for a caller that needs it.
211
- return done(error="Error: client failed: given messages are sent to chat completions only,"
212
- " not the Responses API")
213
- url = base + "/responses"
214
- body = json.dumps({"model": model, "input": [{"role": "user", "content": prompt}],
215
- "stream": True, "store": False,
216
- **({"instructions": system} if system else {})}).encode()
217
- else:
218
- url = base + "/chat/completions"
219
- if messages is None:
220
- messages = ([{"role": "system", "content": system}] if system else []) \
221
- + [{"role": "user", "content": prompt}]
222
- body = json.dumps({"model": model, "messages": messages, **_options(alias)}).encode()
223
- headers = {"Content-Type": "application/json", "User-Agent": "benchwarmers",
224
- **reg.get("headers", {})}
225
- if name := reg.get("api_key_name", ""):
226
- if why := connect.fresh(name): # a signed-in token past renewal
227
- return done(error=f"Error: client failed: {why}")
228
- # As llm does: a named key that is not set stops here, rather than
229
- # sending an unauthenticated call whose 401 would read as entitlement.
230
- if not (key := endpoints.keys().get(name, "")):
231
- return done(error=f"Error: client failed: no key named {name!r} is set"
232
- f" (llm keys set {name})")
233
- headers["Authorization"] = f"Bearer {key}"
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
- for attempt in range(MAX_RETRIES + 1):
236
- left = deadline - time.monotonic()
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 done(timed_out=True)
239
- request = urllib.request.Request(url, body, headers)
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
- raw = _read(r, deadline)
245
- if raw is None:
246
- return done(timed_out=True)
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
- res["status"] = e.code
261
- if not (e.code in RETRY_STATUS or e.code >= 500) or attempt == MAX_RETRIES:
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 done(error=err)
583
+ if time.monotonic() + wait >= relay.cutoff(deadline):
584
+ return {"status": e.code, "error": err, "attempts": attempts}
266
585
  time.sleep(wait)
267
- continue
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 done(timed_out=True)
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 == MAX_RETRIES or time.monotonic() + wait >= deadline:
277
- return done(error=err)
595
+ if attempt >= retries or time.monotonic() + wait >= relay.cutoff(deadline):
596
+ return {"error": err, "attempts": attempts}
278
597
  time.sleep(wait)
279
- continue
598
+ attempt += 1
280
599
  except ValueError as e: # over MAX_REPLY, or a malformed URL
281
- return done(status=status, error=f"Error: client failed: {e}")
282
- if responses:
283
- return done(status=status, **_responses(raw))
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
- data = json.loads(raw or b"{}")
286
- except ValueError:
287
- return done(status=status, error="Error: client failed: the reply was not JSON: "
288
- + raw[:1000].decode("utf-8", "replace"))
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
- def _responses(raw: bytes) -> dict:
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 line in raw.decode("utf-8", "replace").splitlines():
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 token counts."""
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 Responses stream parser: an answer, a mid-stream usage limit, a
363
- cut-off stream; and which `llm` failures are only its log."""
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