@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
package/metrics/client.py CHANGED
@@ -1,394 +1,804 @@
1
- """The bench's own model client: one chat completion over urllib, no `llm` subprocess.
2
-
3
- python metrics/client.py <alias> "prompt" # one call; prints what ask_once would return
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.
13
-
14
- THE ERROR TEXT IS A CONTRACT
15
- ----------------------------
16
- Failures are classified by reading their text (suite/run.py `outcome` and
17
- `failure_class`, metrics/learn.py, provider notices), so the text keeps the
18
- markers llm's produced:
19
-
20
- - an HTTP error reads as the openai SDK's, which llm prints:
21
- `Error: Error code: <status> - <body>`, the body parsed from JSON and shown
22
- as Python shows a dict. For a body that is not JSON (an HTML 502 page) the
23
- SDK prints only the text; the status is kept here as well, since 502 and
24
- 503 are markers. A Retry-After header is appended for learn.py.
25
- - a failure on this side (connection refused, unknown alias, bad base URL)
26
- reads `Error: client failed: ...`, the counterpart of ask_once's
27
- `Error: llm exited N: ...`; both are in suite/run.py ERRORS. A bare
28
- "Error: Connection error." would have scored as an answer in the perf tier,
29
- which accepts one-word replies.
30
- - no answer before the deadline is `<<TIMED OUT after Ns>>`;
31
- - an empty answer is the empty string (a reasoning model out of budget), but
32
- a 200 with no choice at all is `client failed`: a broken endpoint, which
33
- "empty" would have kept out of auto-retirement.
34
-
35
- Redirects are not followed (see _NoRedirect): urllib would hand the key to
36
- whatever host a redirect names.
37
-
38
- ENDPOINTS CONNECTED BY SIGN-IN (metrics/connect.py) come through here even
39
- when BENCH_CLIENT is `llm` (see backend()): a token that renews is renewed
40
- just before the call, and ChatGPT's plan is reached through the Responses API
41
- (streamed, unstored) rather than chat completions. `llm -m` cannot do either,
42
- so for those models use `python metrics/client.py <alias> "prompt"`.
43
-
44
- RETRIES MATCH THE SDK UNDER llm: two more tries on a connection error, 408,
45
- 409, 429 or 5xx, waiting the Retry-After when it is 60 s or less, else about
46
- 0.5 then 1 s. Without them this client would surface 429s llm absorbed, and
47
- the comparison would blame the client. A wait that would pass the deadline is
48
- not taken: the error is returned, which says more than a timeout.
49
-
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
- python scripts/test_client.py # both backends on a fake server, same results
56
- """
57
-
58
- from __future__ import annotations
59
-
60
- import email.utils
61
- import http.client
62
- import json
63
- import os
64
- import pathlib
65
- import random
66
- import shutil
67
- import sys
68
- import time
69
- import urllib.error
70
- import urllib.request
71
-
72
- ROOT = pathlib.Path(__file__).resolve().parent.parent
73
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
74
- sys.path.insert(0, str(ROOT / "metrics"))
75
- import connect # noqa: E402
76
- import endpoints # noqa: E402
77
-
78
- OPTIONS = DATA / "config" / "model-options.json"
79
- MAX_RETRIES = 2
80
- RETRY_STATUS = {408, 409, 429}
81
- MAX_REPLY = 32_000_000 # bytes
82
-
83
-
84
- CLIENTS = ("llm", "builtin")
85
- CHOICE = DATA / "config" / "client.json" # the Settings page's choice
86
-
87
-
88
- def chosen() -> tuple[str, str]:
89
- """The configured client and what set it: BENCH_CLIENT ("environment"),
90
- else the Settings page's choice ("settings"), else ("default") `llm` where
91
- the llm CLI is installed and the built-in client where it is not, as in
92
- an install from npm or a container. Read on every call, so a change
93
- applies to the next one."""
94
- env = (os.environ.get("BENCH_CLIENT") or "").strip().lower()
95
- if env:
96
- return env, "environment"
97
- try:
98
- saved = json.loads(CHOICE.read_text(encoding="utf-8")).get("client")
99
- except (OSError, ValueError, AttributeError):
100
- saved = None
101
- return (saved, "settings") if saved in CLIENTS else ("llm" if shutil.which("llm") else "builtin", "default")
102
-
103
-
104
- def backend(alias: str = "") -> str:
105
- """`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."""
108
- if alias:
109
- entry = endpoints.entry_of(alias)
110
- if entry.get("api") == "responses" or (entry.get("connect") or {}).get("refreshes"):
111
- return "builtin"
112
- return chosen()[0]
113
-
114
-
115
- def _options(alias: str) -> dict:
116
- """Per-model request defaults (the inkling pair's max_tokens), as llm applies them."""
117
- try:
118
- data = json.loads(OPTIONS.read_text(encoding="utf-8"))
119
- except (OSError, ValueError):
120
- return {}
121
- opts = data.get(alias)
122
- return dict(opts) if isinstance(opts, dict) else {}
123
-
124
-
125
- def _retry_after(headers) -> float | None:
126
- """Seconds the server asked us to wait, when it is a usable 0-60 s (the SDK's rule)."""
127
- for name, scale in (("retry-after-ms", 0.001), ("retry-after", 1.0)):
128
- raw = headers.get(name) if headers else None
129
- if not raw:
130
- continue
131
- try:
132
- wait = float(raw) * scale
133
- except ValueError:
134
- try: # an HTTP date
135
- wait = email.utils.parsedate_to_datetime(raw).timestamp() - time.time()
136
- except (TypeError, ValueError):
137
- wait = -1
138
- if 0 < wait <= 60:
139
- return wait
140
- return None
141
-
142
-
143
- def _backoff(attempt: int) -> float:
144
- return min(0.5 * 2 ** attempt, 8.0) * (1 - 0.25 * random.random())
145
-
146
-
147
- def _text(content) -> str:
148
- """Message content as text: a string, or a list of parts some providers send."""
149
- if isinstance(content, list):
150
- return "".join(p.get("text") or "" for p in content if isinstance(p, dict))
151
- return content if isinstance(content, str) else ""
152
-
153
-
154
- class _NoRedirect(urllib.request.HTTPRedirectHandler):
155
- """Redirects are refused, so the 3xx surfaces as an HTTPError. urllib would
156
- resend the Authorization header to whatever host a redirect names (httpx,
157
- under llm, strips it), and turns the POST into a body-less GET anyway."""
158
-
159
- def redirect_request(self, *args, **kwargs):
160
- return None
161
-
162
-
163
- _OPENER = urllib.request.build_opener(_NoRedirect)
164
-
165
-
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):
173
- size += len(chunk)
174
- if size > MAX_REPLY:
175
- 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)
180
-
181
-
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": {}}
192
-
193
- def done(**kw) -> dict:
194
- res.update(kw, seconds=round(time.monotonic() - started, 1))
195
- return res
196
-
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}"
234
-
235
- for attempt in range(MAX_RETRIES + 1):
236
- left = deadline - time.monotonic()
237
- if left <= 0:
238
- return done(timed_out=True)
239
- request = urllib.request.Request(url, body, headers)
240
- status = 0
241
- try:
242
- with _OPENER.open(request, timeout=left) as r:
243
- status = r.status
244
- raw = _read(r, deadline)
245
- if raw is None:
246
- return done(timed_out=True)
247
- except urllib.error.HTTPError as e:
248
- try:
249
- text = e.read().decode("utf-8", "replace").strip()
250
- except (OSError, http.client.HTTPException): # the body broke off
251
- text = ""
252
- try:
253
- shown = json.loads(text)
254
- except ValueError:
255
- shown = text
256
- wait = _retry_after(e.headers)
257
- err = f"Error: Error code: {e.code}" + (f" - {shown}" if text else "") \
258
- + (f" Retry-After: {e.headers.get('retry-after')}" if e.headers.get("retry-after") else "") \
259
- + (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)
263
- wait = wait or _backoff(attempt)
264
- if time.monotonic() + wait >= deadline:
265
- return done(error=err)
266
- time.sleep(wait)
267
- continue
268
- except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
269
- reason = getattr(e, "reason", e)
270
- # The deadline itself, or the OS giving up on a connection early
271
- # (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)
274
- err = f"Error: client failed: Connection error. ({reason})"
275
- wait = _backoff(attempt)
276
- if attempt == MAX_RETRIES or time.monotonic() + wait >= deadline:
277
- return done(error=err)
278
- time.sleep(wait)
279
- continue
280
- 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))
284
- 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
305
-
306
-
307
- def _responses(raw: bytes) -> dict:
308
- """A streamed Responses API reply as chat()'s fields. It counts as an answer
309
- only once `response.completed` arrives (OpenAI's rule for the plan flow); a
310
- usage limit can arrive mid-stream as `response.failed`."""
311
- 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
319
- kind = event.get("type", "") if isinstance(event, dict) else ""
320
- if kind == "response.output_text.delta":
321
- deltas.append(event.get("delta") or "")
322
- elif kind in ("response.completed", "response.failed", "response.incomplete", "error"):
323
- final = event
324
- if final is None:
325
- return {"error": "Error: client failed: the stream ended before response.completed"}
326
- resp = final.get("response") or {}
327
- if final["type"] != "response.completed":
328
- err = resp.get("error") or resp.get("incomplete_details") or final
329
- code = err.get("code") if isinstance(err, dict) else ""
330
- return {"error": f"Error: Error code: {code or final['type']} - {err}"}
331
- text = "".join(part.get("text") or "" for item in resp.get("output") or []
332
- if item.get("type") == "message"
333
- for part in item.get("content") or [] if part.get("type") == "output_text")
334
- return {"text": text or "".join(deltas), "model": resp.get("model") or "",
335
- "usage": resp.get("usage") or {}}
336
-
337
-
338
- def output(res: dict, timeout: float) -> str:
339
- """The result as ask_once's text: the answer as llm prints it (with its
340
- closing newline), the error, or the timeout sentinel."""
341
- if res["timed_out"]:
342
- return f"<<TIMED OUT after {timeout:g}s>>"
343
- return res["error"] or (res["text"] + "\n" if res["text"] else "")
344
-
345
-
346
- LOG_LOCKED = "sqlite3.OperationalError: database is locked"
347
-
348
-
349
- def llm_log_failed_only(stdout: str, stderr: str) -> bool:
350
- """True when a non-zero `llm -m` exit is only llm failing to write its own
351
- log after printing the answer. Seats called at once each open llm's
352
- logs.db, and the loser's crash (2026-10-22, inkling-small-free answered
353
- "pong") turned an answer into an error. llm writes the log only once the
354
- response is complete, so stdout is the whole answer; the same lock in the
355
- 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."""
357
- lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
358
- return bool((stdout or "").strip()) and bool(lines) and lines[-1] == LOG_LOCKED
359
-
360
-
361
- 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."""
364
- tb = "Traceback (most recent call last):\n File \"llm/cli.py\", in prompt\n" + LOG_LOCKED + "\n"
365
- assert llm_log_failed_only("pong\n", tb)
366
- assert not llm_log_failed_only("", tb) # locked before the call
367
- assert not llm_log_failed_only("half an ans", "openai.APIError: stream cut\n")
368
- assert not llm_log_failed_only("pong", tb + "Error: something after\n")
369
- ok = (b'event: x\ndata: {"type":"response.output_text.delta","delta":"re"}\n\n'
370
- b'data: {"type":"response.completed","response":{"model":"m","usage":{"total_tokens":3},'
371
- b'"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"ready"}]}]}}\n')
372
- assert _responses(ok) == {"text": "ready", "model": "m", "usage": {"total_tokens": 3}}, _responses(ok)
373
- limit = (b'data: {"type":"response.output_text.delta","delta":"half"}\n'
374
- b'data: {"type":"response.failed","response":{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}}\n')
375
- assert _responses(limit)["error"].startswith("Error: Error code: subscription_sharing_usage_limit_exceeded")
376
- assert "before response.completed" in _responses(b'data: {"type":"response.output_text.delta","delta":"x"}\n')["error"]
377
- print("ok")
378
-
379
-
380
- def main() -> int:
381
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
382
- if sys.argv[1:] == ["--selftest"]:
383
- _selftest()
384
- return 0
385
- if len(sys.argv) != 3:
386
- print(__doc__.split("\n\n")[0])
387
- return 2
388
- res = chat(sys.argv[1], sys.argv[2], timeout=120)
389
- print(output(res, 120))
390
- return 1 if res["error"] or res["timed_out"] else 0
391
-
392
-
393
- if __name__ == "__main__":
394
- sys.exit(main())
1
+ """The bench's own model client: one chat completion over urllib, no `llm` subprocess.
2
+
3
+ python metrics/client.py <alias> "prompt" # one call; prints what ask_once would return
4
+
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.
12
+
13
+ THE ERROR TEXT IS A CONTRACT
14
+ ----------------------------
15
+ Failures are classified by reading their text (suite/run.py `outcome` and
16
+ `failure_class`, metrics/learn.py, provider notices), so the text keeps the
17
+ markers llm's produced:
18
+
19
+ - an HTTP error reads as the openai SDK's, which llm prints:
20
+ `Error: Error code: <status> - <body>`, the body parsed from JSON and shown
21
+ as Python shows a dict. For a body that is not JSON (an HTML 502 page) the
22
+ SDK prints only the text; the status is kept here as well, since 502 and
23
+ 503 are markers. A Retry-After header is appended for learn.py.
24
+ - a failure on this side (connection refused, unknown alias, bad base URL)
25
+ reads `Error: client failed: ...`, the counterpart of ask_once's
26
+ `Error: llm exited N: ...`; both are in suite/run.py ERRORS. A bare
27
+ "Error: Connection error." would have scored as an answer in the perf tier,
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.
32
+ - no answer before the deadline is `<<TIMED OUT after Ns>>`;
33
+ - an empty answer is the empty string (a reasoning model out of budget), but
34
+ a 200 with no choice at all is `client failed`: a broken endpoint, which
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.
38
+
39
+ Redirects are not followed (see _NoRedirect): urllib would hand the key to
40
+ whatever host a redirect names.
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
+
81
+ ENDPOINTS CONNECTED BY SIGN-IN (metrics/connect.py) come through here even
82
+ when BENCH_CLIENT is `llm` (see backend()): a token that renews is renewed
83
+ just before the call, and ChatGPT's plan is reached through the Responses API
84
+ (streamed, unstored) rather than chat completions. `llm -m` cannot do either,
85
+ so for those models use `python metrics/client.py <alias> "prompt"`.
86
+
87
+ RETRIES MATCH THE SDK UNDER llm: two more tries on a connection error, 408,
88
+ 409, 429 or 5xx, waiting the Retry-After when it is 60 s or less, else about
89
+ 0.5 then 1 s. Without them this client would surface 429s llm absorbed, and
90
+ the comparison would blame the client. A wait that would pass the deadline is
91
+ not taken: the error is returned, which says more than a timeout.
92
+
93
+ python scripts/test_client.py # both backends on a fake server, same results
94
+ python scripts/test_quota.py # the ledger, quota records and pacing, on a fake server
95
+ """
96
+
97
+ from __future__ import annotations
98
+
99
+ import datetime as dt
100
+ import email.utils
101
+ import http.client
102
+ import json
103
+ import os
104
+ import pathlib
105
+ import random
106
+ import shutil
107
+ import sys
108
+ import threading
109
+ import time
110
+ import urllib.error
111
+ import urllib.request
112
+
113
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
114
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
115
+ sys.path.insert(0, str(ROOT / "metrics"))
116
+ import connect # noqa: E402
117
+ import endpoints # noqa: E402
118
+
119
+ OPTIONS = DATA / "config" / "model-options.json"
120
+ MAX_RETRIES = 2
121
+ RETRY_STATUS = {408, 409, 429}
122
+ MAX_REPLY = 32_000_000 # bytes
123
+ STOP = threading.Event() # set by a server shutting down
124
+
125
+
126
+ CLIENTS = ("llm", "builtin")
127
+ CHOICE = DATA / "config" / "client.json" # the Settings page's choice
128
+
129
+
130
+ def chosen() -> tuple[str, str]:
131
+ """The configured client and what set it: BENCH_CLIENT ("environment"),
132
+ else the Settings page's choice ("settings"), else ("default") `llm` where
133
+ the llm CLI is installed and the built-in client where it is not, as in
134
+ an install from npm or a container. Read on every call, so a change
135
+ applies to the next one."""
136
+ env = (os.environ.get("BENCH_CLIENT") or "").strip().lower()
137
+ if env:
138
+ return env, "environment"
139
+ try:
140
+ saved = json.loads(CHOICE.read_text(encoding="utf-8")).get("client")
141
+ except (OSError, ValueError, AttributeError):
142
+ saved = None
143
+ return (saved, "settings") if saved in CLIENTS else ("llm" if shutil.which("llm") else "builtin", "default")
144
+
145
+
146
+ def backend(alias: str = "") -> str:
147
+ """`llm` or `builtin`, as chosen(). A model on an endpoint connected by
148
+ sign-in whose token renews, or that speaks only the Responses API
149
+ (ChatGPT's plan), is always `builtin`: llm can do neither."""
150
+ if alias:
151
+ entry = endpoints.entry_of(alias)
152
+ if entry.get("api") == "responses" or (entry.get("connect") or {}).get("refreshes"):
153
+ return "builtin"
154
+ return chosen()[0]
155
+
156
+
157
+ def _options(alias: str) -> dict:
158
+ """Per-model request defaults (the inkling pair's max_tokens), as llm applies them."""
159
+ try:
160
+ data = json.loads(OPTIONS.read_text(encoding="utf-8"))
161
+ except (OSError, ValueError):
162
+ return {}
163
+ opts = data.get(alias)
164
+ return dict(opts) if isinstance(opts, dict) else {}
165
+
166
+
167
+ def _retry_after(headers) -> float | None:
168
+ """Seconds the server asked us to wait, when it is a usable 0-60 s (the SDK's rule)."""
169
+ for name, scale in (("retry-after-ms", 0.001), ("retry-after", 1.0)):
170
+ raw = headers.get(name) if headers else None
171
+ if not raw:
172
+ continue
173
+ try:
174
+ wait = float(raw) * scale
175
+ except ValueError:
176
+ try: # an HTTP date
177
+ wait = email.utils.parsedate_to_datetime(raw).timestamp() - time.time()
178
+ except (TypeError, ValueError):
179
+ wait = -1
180
+ if 0 < wait <= 60:
181
+ return wait
182
+ return None
183
+
184
+
185
+ def _backoff(attempt: int) -> float:
186
+ return min(0.5 * 2 ** attempt, 8.0) * (1 - 0.25 * random.random())
187
+
188
+
189
+ def _text(content) -> str:
190
+ """Message content as text: a string, or a list of parts some providers send."""
191
+ if isinstance(content, list):
192
+ return "".join(p.get("text") or "" for p in content if isinstance(p, dict))
193
+ return content if isinstance(content, str) else ""
194
+
195
+
196
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
197
+ """Redirects are refused, so the 3xx surfaces as an HTTPError. urllib would
198
+ resend the Authorization header to whatever host a redirect names (httpx,
199
+ under llm, strips it), and turns the POST into a body-less GET anyway."""
200
+
201
+ def redirect_request(self, *args, **kwargs):
202
+ return None
203
+
204
+
205
+ _OPENER = urllib.request.build_opener(_NoRedirect)
206
+
207
+
208
+ class _Late(Exception):
209
+ """The deadline passed while the reply was arriving."""
210
+
211
+
212
+ class _Broken(Exception):
213
+ """The reply broke off part way (the connection dropped mid-stream)."""
214
+
215
+
216
+ def _wait_at_most(response, deadline: float) -> None:
217
+ """The socket's timeout is per wait, so it is cut to what is left of the
218
+ deadline before each read: a server trickling bytes cannot hold a seat
219
+ past it. Best effort: the socket sits three wrappers down."""
220
+ sock = getattr(getattr(getattr(response, "fp", None), "raw", None), "_sock", None)
221
+ if sock is not None:
222
+ try:
223
+ sock.settimeout(max(deadline - time.monotonic(), 0.01))
224
+ except OSError:
225
+ pass
226
+
227
+
228
+ def _chunks(response, deadline: float):
229
+ """The body in pieces as they arrive. Raises _Late past the deadline (or
230
+ when STOP is set), ValueError past MAX_REPLY, since the gateway is a
231
+ long-lived process, and _Broken if the connection drops part way."""
232
+ size = 0
233
+ while True:
234
+ if STOP.is_set() or time.monotonic() > deadline:
235
+ raise _Late
236
+ _wait_at_most(response, deadline)
237
+ try:
238
+ chunk = response.read1(65536)
239
+ except TimeoutError:
240
+ raise _Late from None
241
+ except (OSError, http.client.HTTPException) as e:
242
+ raise _Broken(str(e) or type(e).__name__) from None
243
+ if not chunk:
244
+ return
245
+ size += len(chunk)
246
+ if size > MAX_REPLY:
247
+ raise ValueError(f"the reply is over {MAX_REPLY // 1_000_000} MB")
248
+ yield chunk
249
+
250
+
251
+ def _lines(chunks):
252
+ """Decoded lines from byte chunks, split as they arrive."""
253
+ buf = b""
254
+ for chunk in chunks:
255
+ buf += chunk
256
+ *done, buf = buf.split(b"\n")
257
+ for line in done:
258
+ yield line.rstrip(b"\r").decode("utf-8", "replace")
259
+ if buf:
260
+ yield buf.rstrip(b"\r").decode("utf-8", "replace")
261
+
262
+
263
+ def _events(lines):
264
+ """Server-sent events' data, parsed: a dict per event, the string
265
+ "[DONE]" for the end marker. Comments (`: OPENROUTER PROCESSING`) and
266
+ event names are skipped; data lines of one event are joined, or, when
267
+ they do not parse joined, read one by one: some servers leave out the
268
+ blank line between events."""
269
+ data: list[str] = []
270
+
271
+ def flush() -> list:
272
+ lines, payload = list(data), "\n".join(data).strip()
273
+ data.clear()
274
+ if not payload:
275
+ return []
276
+ try:
277
+ return ["[DONE]" if payload == "[DONE]" else json.loads(payload)]
278
+ except ValueError:
279
+ pass
280
+ out = []
281
+ for one in lines:
282
+ try:
283
+ out.append("[DONE]" if one.strip() == "[DONE]" else json.loads(one))
284
+ except ValueError:
285
+ continue
286
+ return out
287
+ for line in lines:
288
+ if not line:
289
+ yield from flush()
290
+ elif line.startswith("data:"):
291
+ data.append(line[5:].lstrip(" "))
292
+ yield from flush()
293
+
294
+
295
+ class _Relay:
296
+ """on_delta, kept from breaking the call: one that raises is dropped."""
297
+
298
+ def __init__(self, on_delta, started: float):
299
+ self.fn, self.started, self.first = on_delta, started, None
300
+
301
+ def __call__(self, kind: str, piece: str) -> None:
302
+ if not piece:
303
+ return
304
+ if self.first is None:
305
+ self.first = round(time.monotonic() - self.started, 2)
306
+ if self.fn is not None:
307
+ try:
308
+ self.fn(kind, piece)
309
+ except Exception: # noqa: BLE001
310
+ self.fn = None
311
+
312
+
313
+ def _chat_stream(events, relay: _Relay) -> dict:
314
+ """A streamed chat completion as chat()'s fields."""
315
+ text, reasoning, model, usage, finish, seen, done = [], [], "", {}, "", False, False
316
+ for ev in events:
317
+ if ev == "[DONE]":
318
+ done = True
319
+ break
320
+ if not isinstance(ev, dict):
321
+ continue
322
+ # An error sent mid-stream, as some relays do, or as OpenRouter does:
323
+ # with a choice whose finish_reason is "error", after part of an answer.
324
+ choices = ev.get("choices")
325
+ if ev.get("error") or (isinstance(choices, list) and choices and isinstance(choices[0], dict)
326
+ and choices[0].get("finish_reason") == "error"):
327
+ err = ev.get("error") or {}
328
+ code = (err.get("code") if isinstance(err, dict) else None) or "stream"
329
+ return {"error": f"Error: Error code: {code} - {ev}"}
330
+ model = ev.get("model") or model
331
+ if isinstance(ev.get("usage"), dict) and ev["usage"]:
332
+ usage = ev["usage"]
333
+ if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
334
+ continue
335
+ seen = True
336
+ delta = choices[0].get("delta") or choices[0].get("message") or {}
337
+ if isinstance(delta, dict):
338
+ if piece := _text(delta.get("reasoning_content") or delta.get("reasoning")):
339
+ reasoning.append(piece)
340
+ relay("reasoning", piece)
341
+ if piece := _text(delta.get("content")):
342
+ text.append(piece)
343
+ relay("text", piece)
344
+ finish = choices[0].get("finish_reason") or finish
345
+ if not seen:
346
+ return {"error": "Error: client failed: the reply had no choices: the stream sent none"}
347
+ if not (done or finish):
348
+ return {"error": f"Error: client failed: the stream broke off before it finished"
349
+ f" ({len(''.join(text))} characters in)"}
350
+ return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model, "usage": usage,
351
+ "finish": finish}
352
+
353
+
354
+ def _chat_json(raw: bytes) -> dict:
355
+ """A plain JSON chat completion (an upstream that does not stream) as chat()'s fields."""
356
+ try:
357
+ data = json.loads(raw or b"{}")
358
+ except ValueError:
359
+ return {"error": "Error: client failed: the reply was not JSON: " + raw[:1000].decode("utf-8", "replace")}
360
+ if not isinstance(data, dict):
361
+ return {"error": f"Error: client failed: unexpected reply: {str(data)[:1000]}"}
362
+ # Some relays answer 200 with an error object instead of a status.
363
+ if data.get("error") and not data.get("choices"):
364
+ code = (data["error"].get("code") if isinstance(data["error"], dict) else None) or 200
365
+ return {"error": f"Error: Error code: {code} - {data}"}
366
+ choices = data.get("choices")
367
+ # A 200 with no choice is a broken endpoint, not an empty answer: "empty"
368
+ # is kept out of auto-retirement as a budget problem.
369
+ if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
370
+ return {"error": f"Error: client failed: the reply had no choices: {str(data)[:1000]}"}
371
+ message = choices[0].get("message") or {}
372
+ return {"text": _text(message.get("content")),
373
+ "reasoning": _text(message.get("reasoning_content") or message.get("reasoning")),
374
+ "model": data.get("model") or "", "usage": data.get("usage") or {},
375
+ "finish": choices[0].get("finish_reason") or ""}
376
+
377
+
378
+ def _reply(response, deadline: float, responses: bool, relay: _Relay) -> dict:
379
+ """The reply's fields, read as it arrives. Raises _Late past the deadline."""
380
+ ctype = (response.headers.get("content-type") or "").lower()
381
+ try:
382
+ if responses:
383
+ return _responses_events(_events(_lines(_chunks(response, deadline))), relay)
384
+ if "event-stream" in ctype:
385
+ return _chat_stream(_events(_lines(_chunks(response, deadline))), relay)
386
+ raw = b"".join(_chunks(response, deadline))
387
+ except _Broken as e:
388
+ # Not retried: what arrived may already have been relayed.
389
+ return {"error": f"Error: client failed: the reply broke off part way ({e})"}
390
+ if raw.lstrip().startswith(b"data:"): # a stream sent without its content type
391
+ return _chat_stream(_events(_lines([raw])), relay)
392
+ return _chat_json(raw)
393
+
394
+
395
+ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses: bool,
396
+ relay: _Relay, observe) -> dict:
397
+ """Send the call with the SDK's retries; chat()'s fields plus `attempts`.
398
+ `observe(status, headers, body)` sees every response, for quota records."""
399
+ attempts, attempt, fell_back = 0, 0, False
400
+ while True:
401
+ left = deadline - time.monotonic()
402
+ if left <= 0:
403
+ return {"timed_out": True, "attempts": attempts}
404
+ request = urllib.request.Request(url, json.dumps(payload).encode(), headers)
405
+ status = 0
406
+ attempts += 1
407
+ try:
408
+ with _OPENER.open(request, timeout=left) as r:
409
+ status = r.status
410
+ observe(status, r.headers, None)
411
+ got = _reply(r, deadline, responses, relay)
412
+ return {"status": status, "attempts": attempts, **got}
413
+ except _Late:
414
+ return {"status": status, "timed_out": True, "attempts": attempts}
415
+ except urllib.error.HTTPError as e:
416
+ try:
417
+ text = e.read().decode("utf-8", "replace").strip()
418
+ except (OSError, http.client.HTTPException): # the body broke off
419
+ text = ""
420
+ try:
421
+ shown = json.loads(text)
422
+ except ValueError:
423
+ shown = text
424
+ observe(e.code, e.headers, shown)
425
+ # An upstream that does not know stream_options, or will not stream:
426
+ # the same call once more without them, not counted as a retry.
427
+ if (not responses and not fell_back and e.code in (400, 422) and payload.get("stream")
428
+ and "stream" in text.lower()):
429
+ drop = ("stream_options",) if "stream_options" in text.lower() else ("stream", "stream_options")
430
+ payload = {k: v for k, v in payload.items() if k not in drop}
431
+ fell_back = True
432
+ continue
433
+ wait = _retry_after(e.headers)
434
+ err = f"Error: Error code: {e.code}" + (f" - {shown}" if text else "") \
435
+ + (f" Retry-After: {e.headers.get('retry-after')}" if e.headers.get("retry-after") else "") \
436
+ + (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 == MAX_RETRIES:
438
+ return {"status": e.code, "error": err, "attempts": attempts}
439
+ wait = wait or _backoff(attempt)
440
+ if time.monotonic() + wait >= deadline:
441
+ return {"status": e.code, "error": err, "attempts": attempts}
442
+ time.sleep(wait)
443
+ attempt += 1
444
+ except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
445
+ reason = getattr(e, "reason", e)
446
+ # The deadline itself, or the OS giving up on a connection early
447
+ # (a dropped SYN), which is a connection error and is retried.
448
+ if isinstance(reason, TimeoutError) and time.monotonic() >= deadline - 0.5:
449
+ return {"timed_out": True, "attempts": attempts}
450
+ err = f"Error: client failed: Connection error. ({reason})"
451
+ wait = _backoff(attempt)
452
+ if attempt == MAX_RETRIES or time.monotonic() + wait >= deadline:
453
+ return {"error": err, "attempts": attempts}
454
+ time.sleep(wait)
455
+ attempt += 1
456
+ except ValueError as e: # over MAX_REPLY, or a malformed URL
457
+ return {"status": status, "error": f"Error: client failed: {e}", "attempts": attempts}
458
+
459
+
460
+ def _estimate(prompt: str, system: str, messages: list[dict] | None) -> int:
461
+ """Tokens a prompt will take, roughly (four characters a token), for
462
+ pacing a tokens-per-minute limit before the provider has counted."""
463
+ if messages is None:
464
+ return (len(prompt) + len(system)) // 4 + 1
465
+ return sum(len(m.get("content") if isinstance(m.get("content"), str) else _text(m.get("content")))
466
+ for m in messages if isinstance(m, dict)) // 4 + 1
467
+
468
+
469
+ def _paid_hold(alias: str, served: str) -> str:
470
+ """The free-tier guard's refusal, or "" when the model may be called."""
471
+ if os.environ.get("BENCH_ALLOW_PAID") == "1" or alias in (endpoints.connections().get("allow_paid") or ()):
472
+ return ""
473
+ try:
474
+ import alerts
475
+ alert = alerts.active().get(f"model:paid:{served}") or {}
476
+ except Exception: # noqa: BLE001
477
+ return ""
478
+ if alert.get("kind") != "went paid":
479
+ return ""
480
+ return (f"Error: client failed: not sent: the free-tier guard holds {alias}, which is listed with a"
481
+ f" price now ({alert.get('message', '')[:160]}), so a call may be billed: payment required"
482
+ f" is assumed. Allow it by adding {alias!r} to allow_paid in config/connections.json,"
483
+ f" or for one run with BENCH_ALLOW_PAID=1.")
484
+
485
+
486
+ def _budget(payload: dict, usage: dict, alias: str) -> tuple[str, int, int] | None:
487
+ """(option name, budget used, the larger budget) for retrying an empty
488
+ answer that ran out of output budget, or None when it cannot grow."""
489
+ key = "max_completion_tokens" if "max_completion_tokens" in payload else "max_tokens"
490
+ try:
491
+ current = int(float(payload.get(key) or usage.get("completion_tokens") or 0))
492
+ except (TypeError, ValueError):
493
+ return None
494
+ if current <= 0:
495
+ return None
496
+ bigger = current * 2
497
+ try:
498
+ import catalog
499
+ ceiling = catalog.info(alias).get("max_output")
500
+ except Exception: # noqa: BLE001
501
+ ceiling = None
502
+ if isinstance(ceiling, int) and ceiling > 0:
503
+ bigger = min(bigger, ceiling)
504
+ return (key, current, bigger) if bigger > current else None
505
+
506
+
507
+ def _swapped(requested: str, served: str) -> bool:
508
+ """The reply names another model, beyond spelling: a vendor prefix, `:free`
509
+ and a date or version tail are the same model (sources.norm_model)."""
510
+ if not served or not requested or served == requested:
511
+ return False
512
+ import sources
513
+ return sources.norm_model(served) != sources.norm_model(requested)
514
+
515
+
516
+ def _caller() -> str:
517
+ """The script making the call: gateway, perf/run, suite/run, serve, ..."""
518
+ p = pathlib.Path(sys.argv[0] if sys.argv and sys.argv[0] else "python")
519
+ return f"{p.parent.name}/{p.stem}" if p.parent.name in ("perf", "suite", "scripts", "metrics") else p.stem
520
+
521
+
522
+ def chat(alias: str, prompt: str, system: str = "", timeout: float = 420,
523
+ messages: list[dict] | None = None, on_delta=None, purpose: str = "") -> dict:
524
+ """One chat completion. Returns {text, reasoning, error, timed_out, status,
525
+ model (as served), usage, finish, first_token_s, attempts, seconds}, and
526
+ `budget_retry` when an empty answer was tried again; `error` is "" on
527
+ success, else the contract text. `messages`, when given, are sent as they
528
+ are instead of system + prompt: the gateway's requests that carry images,
529
+ which `llm -m` cannot send. `on_delta(kind, text)` hears the answer
530
+ ("text") and the reasoning ("reasoning") as they arrive. `purpose` goes
531
+ into the ledger line beside the calling script's name."""
532
+ started, t0 = time.monotonic(), time.time()
533
+ deadline = started + timeout
534
+ res = {"text": "", "reasoning": "", "error": "", "timed_out": False, "status": 0,
535
+ "model": "", "usage": {}, "finish": "", "first_token_s": None, "attempts": 0}
536
+ ctx = {"connection": "", "endpoint": "", "ticket": None, "requested": "", "refused": False}
537
+ try:
538
+ _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started)
539
+ finally:
540
+ res["seconds"] = round(time.monotonic() - started, 1)
541
+ _record(alias, res, ctx, t0, purpose)
542
+ return res
543
+
544
+
545
+ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started) -> None:
546
+ reg = endpoints.registrations().get(alias)
547
+ if reg is None:
548
+ res["error"] = f"Error: client failed: no model is registered as {alias!r}"
549
+ return
550
+ base = (reg.get("api_base") or "").rstrip("/")
551
+ # urllib would also open file:// and ftp:// URLs given as a base.
552
+ if not base.startswith(("http://", "https://")):
553
+ res["error"] = f"Error: client failed: endpoint base must be http(s), not {base!r}"
554
+ return
555
+ model = ctx["requested"] = reg.get("model_name") or alias
556
+ if responses := endpoints.entry_of(alias).get("api") == "responses":
557
+ # ChatGPT's plan (metrics/connect.py): streamed, unstored, no system
558
+ # message items, no sampling or length options.
559
+ if messages is not None:
560
+ # ponytail: chat parts are not Responses input items; converting
561
+ # them (input_text, input_image) waits for a caller that needs it.
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}}
575
+ headers = {"Content-Type": "application/json", "User-Agent": "benchwarmers",
576
+ **reg.get("headers", {})}
577
+ if name := reg.get("api_key_name", ""):
578
+ if why := connect.fresh(name): # a signed-in token past renewal
579
+ res["error"] = f"Error: client failed: {why}"
580
+ return
581
+ # As llm does: a named key that is not set stops here, rather than
582
+ # sending an unauthenticated call whose 401 would read as entitlement.
583
+ if not (key := endpoints.keys().get(name, "")):
584
+ res["error"] = f"Error: client failed: no key named {name!r} is set (llm keys set {name})"
585
+ return
586
+ headers["Authorization"] = f"Bearer {key}"
587
+
588
+ try:
589
+ if str(ROOT / "suite") not in sys.path: # route reads the registry through suite/run.py
590
+ sys.path.insert(0, str(ROOT / "suite"))
591
+ import route
592
+ ctx["connection"] = route.connection_of(alias)
593
+ ctx["endpoint"] = endpoints.endpoint_name(reg) or ""
594
+ except Exception: # noqa: BLE001 -- bookkeeping never stops a call
595
+ ctx["connection"] = ctx["connection"] or "?"
596
+ if why := _paid_hold(alias, model):
597
+ res["error"], ctx["refused"] = why, True
598
+ return
599
+ try:
600
+ import pacing
601
+ ctx["ticket"], why = pacing.admit(alias, ctx["connection"], _estimate(prompt, system, messages),
602
+ deadline - time.monotonic())
603
+ except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
604
+ why = ""
605
+ if why:
606
+ res["error"], ctx["refused"] = why, True
607
+ return
608
+ # The ledger's `t`: when the call went out, after any wait for room, since
609
+ # that is the moment a provider's window (and pacing's) counts it from.
610
+ ctx["sent"] = time.time()
611
+
612
+ def observe(status, hdrs, body) -> None:
613
+ try:
614
+ import quota
615
+ quota.observe(alias, ctx["connection"], base, status, hdrs, body)
616
+ except Exception: # noqa: BLE001
617
+ pass
618
+
619
+ relay = _Relay(on_delta, started)
620
+ got = _exchange(url, payload, headers, deadline, responses, relay, observe)
621
+ if (not responses and not got.get("error") and not got.get("timed_out") and got.get("text") == ""
622
+ and got.get("finish") == "length" and (grow := _budget(payload, got.get("usage") or {}, alias))):
623
+ key, used, bigger = grow
624
+ again = _exchange(url, {**payload, key: bigger}, headers, deadline, responses, relay, observe)
625
+ attempts = got.get("attempts", 0) + again.get("attempts", 0)
626
+ if again.get("error") or again.get("timed_out"):
627
+ # The empty answer stands: the retry's failure (a 400 for a budget
628
+ # over the model's cap, a 429) is not the model's, and an error
629
+ # here would count toward auto-retirement where "empty" does not.
630
+ got["budget_retry"] = {key: [used, bigger], "answered": False,
631
+ "failed": (again.get("error") or "timed out")[:200]}
632
+ else:
633
+ got = again
634
+ got["budget_retry"] = {key: [used, bigger], "answered": bool(again.get("text"))}
635
+ got["attempts"] = attempts
636
+ res.update(got)
637
+ res["first_token_s"] = relay.first
638
+
639
+
640
+ def _record(alias: str, res: dict, ctx: dict, t0: float, purpose: str) -> None:
641
+ """The ledger line, the silent-swap note, and the in-flight slot freed.
642
+ Never raises: the answer matters more than its bookkeeping."""
643
+ try:
644
+ swap = not res["error"] and _swapped(ctx["requested"], res["model"])
645
+ if swap:
646
+ import learn
647
+ # Noted and logged when it is new; every line in the ledger says so.
648
+ if (learn.learned().get(alias, {}).get("served_as") or {}).get("served") != res["model"]:
649
+ learn.note_served_as(alias, ctx["requested"], res["model"])
650
+ print(f"note: {alias} asked for {ctx['requested']} and was served {res['model']}"
651
+ " (a silent swap?)", file=sys.stderr)
652
+ status = ("refused" if ctx["refused"] else "timeout" if res["timed_out"] else
653
+ "error" if res["error"] else "ok" if res["text"] else "empty")
654
+ import ledger
655
+ t = ctx.get("sent") or t0
656
+ row = {"t": round(t, 3), "at": dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds"),
657
+ "alias": alias, "connection": ctx["connection"], "endpoint": ctx["endpoint"],
658
+ "served": res["model"], "status": status, "attempts": res.get("attempts", 0),
659
+ **ledger.tokens(res["usage"]), "seconds": res["seconds"],
660
+ "caller": _caller(), "purpose": purpose or os.environ.get("BENCH_PURPOSE", "")}
661
+ if swap:
662
+ row["swap"] = True
663
+ if res.get("budget_retry"):
664
+ row["budget_retry"] = res["budget_retry"]
665
+ ledger.append(row)
666
+ except Exception as e: # noqa: BLE001
667
+ # The slot stays taken until it expires (the call's deadline and a
668
+ # minute): released now, this call would count nowhere at all.
669
+ print(f"note: the usage ledger was not written: {e}", file=sys.stderr)
670
+ return
671
+ try:
672
+ import pacing
673
+ pacing.release(ctx["ticket"])
674
+ except Exception: # noqa: BLE001 -- the lock timed out: it expires instead
675
+ pass
676
+
677
+
678
+ def _responses_events(events, relay: _Relay) -> dict:
679
+ """A streamed Responses API reply as chat()'s fields. It counts as an answer
680
+ only once `response.completed` arrives (OpenAI's rule for the plan flow); a
681
+ usage limit can arrive mid-stream as `response.failed`."""
682
+ deltas, final = [], None
683
+ for event in events:
684
+ kind = event.get("type", "") if isinstance(event, dict) else ""
685
+ if kind == "response.output_text.delta":
686
+ deltas.append(event.get("delta") or "")
687
+ relay("text", event.get("delta") or "")
688
+ elif kind == "response.reasoning_summary_text.delta":
689
+ relay("reasoning", event.get("delta") or "")
690
+ elif kind in ("response.completed", "response.failed", "response.incomplete", "error"):
691
+ final = event
692
+ if final is None:
693
+ return {"error": "Error: client failed: the stream ended before response.completed"}
694
+ resp = final.get("response") or {}
695
+ if final["type"] != "response.completed":
696
+ err = resp.get("error") or resp.get("incomplete_details") or final
697
+ code = err.get("code") if isinstance(err, dict) else ""
698
+ return {"error": f"Error: Error code: {code or final['type']} - {err}"}
699
+ text = "".join(part.get("text") or "" for item in resp.get("output") or []
700
+ if item.get("type") == "message"
701
+ for part in item.get("content") or [] if part.get("type") == "output_text")
702
+ return {"text": text or "".join(deltas), "model": resp.get("model") or "",
703
+ "usage": resp.get("usage") or {}}
704
+
705
+
706
+ def _responses(raw: bytes) -> dict:
707
+ """The same, from a whole body (the self-check's canned streams)."""
708
+ return _responses_events(_events(_lines([raw])), _Relay(None, time.monotonic()))
709
+
710
+
711
+ def output(res: dict, timeout: float) -> str:
712
+ """The result as ask_once's text: the answer as llm prints it (with its
713
+ closing newline), the error, or the timeout sentinel."""
714
+ if res["timed_out"]:
715
+ return f"<<TIMED OUT after {timeout:g}s>>"
716
+ return res["error"] or (res["text"] + "\n" if res["text"] else "")
717
+
718
+
719
+ LOG_LOCKED = "sqlite3.OperationalError: database is locked"
720
+
721
+
722
+ def llm_log_failed_only(stdout: str, stderr: str) -> bool:
723
+ """True when a non-zero `llm -m` exit is only llm failing to write its own
724
+ log after printing the answer. Seats called at once each open llm's
725
+ logs.db, and the loser's crash (2026-10-22, inkling-small-free answered
726
+ "pong") turned an answer into an error. llm writes the log only once the
727
+ response is complete, so stdout is the whole answer; the same lock in the
728
+ migration llm runs before the call leaves stdout empty and stays an error.
729
+ Not `llm -n`: metrics/cost.py reads that log for the llm backend's tokens."""
730
+ lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
731
+ return bool((stdout or "").strip()) and bool(lines) and lines[-1] == LOG_LOCKED
732
+
733
+
734
+ def _selftest() -> None:
735
+ """The stream parsers (chat completions and Responses): an answer with its
736
+ reasoning and usage, a mid-stream error, a cut-off stream; and which `llm`
737
+ failures are only its log."""
738
+ tb = "Traceback (most recent call last):\n File \"llm/cli.py\", in prompt\n" + LOG_LOCKED + "\n"
739
+ assert llm_log_failed_only("pong\n", tb)
740
+ assert not llm_log_failed_only("", tb) # locked before the call
741
+ assert not llm_log_failed_only("half an ans", "openai.APIError: stream cut\n")
742
+ assert not llm_log_failed_only("pong", tb + "Error: something after\n")
743
+ ok = (b'event: x\ndata: {"type":"response.output_text.delta","delta":"re"}\n\n'
744
+ b'data: {"type":"response.completed","response":{"model":"m","usage":{"total_tokens":3},'
745
+ b'"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"ready"}]}]}}\n')
746
+ assert _responses(ok) == {"text": "ready", "model": "m", "usage": {"total_tokens": 3}}, _responses(ok)
747
+ limit = (b'data: {"type":"response.output_text.delta","delta":"half"}\n'
748
+ b'data: {"type":"response.failed","response":{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}}\n')
749
+ assert _responses(limit)["error"].startswith("Error: Error code: subscription_sharing_usage_limit_exceeded")
750
+ assert "before response.completed" in _responses(b'data: {"type":"response.output_text.delta","delta":"x"}\n')["error"]
751
+
752
+ def chunks(*evs): # each event split across two network reads
753
+ body = "".join((e if e.startswith(":") else f"data: {e}") + "\n\n" if isinstance(e, str)
754
+ else f"data: {json.dumps(e)}\n\n" for e in evs).encode()
755
+ return [body[:len(body) // 2], body[len(body) // 2:]]
756
+ heard = []
757
+ relay = _Relay(lambda k, t: heard.append((k, t)), time.monotonic())
758
+ got = _chat_stream(_events(_lines(chunks(
759
+ ": OPENROUTER PROCESSING",
760
+ {"model": "m-2", "choices": [{"delta": {"reasoning_content": "hmm "}}]},
761
+ {"choices": [{"delta": {"reasoning": "so"}}]},
762
+ {"choices": [{"delta": {"content": "rea"}}]},
763
+ {"choices": [{"delta": {"content": [{"type": "text", "text": "dy"}]}, "finish_reason": "stop"}]},
764
+ {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 5}}, "[DONE]"))), relay)
765
+ assert got == {"text": "ready", "reasoning": "hmm so", "model": "m-2", "finish": "stop",
766
+ "usage": {"prompt_tokens": 3, "completion_tokens": 5}}, got
767
+ assert heard == [("reasoning", "hmm "), ("reasoning", "so"), ("text", "rea"), ("text", "dy")], heard
768
+ assert relay.first is not None
769
+ cut = _chat_stream(_events(_lines(chunks({"choices": [{"delta": {"content": "half an"}}]}))), _Relay(None, 0))
770
+ assert "broke off before it finished (7 characters in)" in cut["error"], cut
771
+ err = _chat_stream(_events(_lines(chunks({"error": {"code": 429, "message": "Rate limit exceeded"}}))),
772
+ _Relay(None, 0))
773
+ assert err["error"].startswith("Error: Error code: 429 - "), err
774
+ # OpenRouter's mid-stream error: part of an answer, then a choice finishing "error".
775
+ err = _chat_stream(_events(_lines(chunks(
776
+ {"choices": [{"delta": {"content": "half"}}]},
777
+ {"error": {"code": "server_error", "message": "upstream died"},
778
+ "choices": [{"delta": {"content": ""}, "finish_reason": "error"}]}, "[DONE]"))), _Relay(None, 0))
779
+ assert err["error"].startswith("Error: Error code: server_error - "), err
780
+ assert "no choices" in _chat_stream(_events(_lines(chunks("[DONE]"))), _Relay(None, 0))["error"]
781
+ bad = _Relay(lambda k, t: 1 / 0, 0) # a callback that raises is dropped, the call goes on
782
+ bad("text", "x")
783
+ assert bad.fn is None
784
+ assert _chat_json(b'{"choices":[{"message":{"content":"","reasoning":"r"},"finish_reason":"length"}]}') \
785
+ == {"text": "", "reasoning": "r", "model": "", "usage": {}, "finish": "length"}
786
+ assert not _swapped("openai/gpt-oss-120b:free", "gpt-oss-120b") and _swapped("llama-3.3-70b", "llama-3.1-8b")
787
+ print("ok")
788
+
789
+
790
+ def main() -> int:
791
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
792
+ if sys.argv[1:] == ["--selftest"]:
793
+ _selftest()
794
+ return 0
795
+ if len(sys.argv) != 3:
796
+ print(__doc__.split("\n\n")[0])
797
+ return 2
798
+ res = chat(sys.argv[1], sys.argv[2], timeout=120)
799
+ print(output(res, 120))
800
+ return 1 if res["error"] or res["timed_out"] else 0
801
+
802
+
803
+ if __name__ == "__main__":
804
+ sys.exit(main())