@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -65
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +214 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -438
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1756 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/client.py
CHANGED
|
@@ -1,394 +1,394 @@
|
|
|
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 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())
|