inferencekit-cli 0.1.0__py3-none-any.whl

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 (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,446 @@
1
+ """A streaming client for OpenAI-compatible servers, on plain asyncio sockets (HTTP/1.1, SSE).
2
+
3
+ Why raw sockets: the client is the only timer for published numbers, so it should add as little as
4
+ possible between "bytes arrived" and "timestamp taken", and it should not pull in an HTTP stack whose
5
+ buffering we cannot see. Every timestamp is ``time.perf_counter_ns()`` (monotonic, nanoseconds):
6
+
7
+ t_send just before the TCP connection is opened (TTFT and end-to-end start here; the connect
8
+ time is recorded separately as connect_ms, about 0.1 ms on localhost)
9
+ t_event when the bytes holding an SSE event were read from the socket
10
+ t_first the first event that carries generated tokens (token ids, or non-empty text)
11
+ t_last the last event that carries generated tokens
12
+ t_done the "[DONE]" sentinel, or end of stream
13
+
14
+ Token ids are collected from every place a server is known to put them (see profiles.py): the
15
+ choice's ``token_ids`` (vLLM, TRT-LLM completions), logprob entries with an ``id`` (llama.cpp) or a
16
+ ``token_id:N`` token (vLLM return_tokens_as_token_ids), and ``sglext.output_ids`` (SGLang). An event
17
+ may carry more than one token (speculative decoding, server-side coalescing), so tokens are counted
18
+ from ids, never from the number of events.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import contextlib
25
+ import json
26
+ import os
27
+ import re
28
+ import ssl
29
+ import time
30
+ from dataclasses import asdict, dataclass, field
31
+ from urllib.parse import urlsplit
32
+
33
+ _TOKEN_ID = re.compile(r"^token_id:(-?\d+)$")
34
+ MAX_ERROR_BODY = 2000
35
+ # Addresses in error text: socket errors name the peer ("Connect call failed ('203.0.113.7', 8000)"), and
36
+ # a proxy's error page can too. Evidence is published, so they are replaced before anything is stored.
37
+ _IPV4 = re.compile(r"(?<![\d.])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])")
38
+ _IPV6 = re.compile(r"(?<![\w:])(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?:%\w+)?(?![\w:])")
39
+ _PORT_AFTER = re.compile(r"(<server>|<addr>)(['\]]?)(\s*[,:]\s*)\d{1,5}\b")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Endpoint:
44
+ """Where requests go. ``base_url`` is the server root or its /v1 root."""
45
+
46
+ scheme: str
47
+ host: str
48
+ port: int
49
+ prefix: str # path prefix before /v1/...
50
+ api_key: str | None = None
51
+
52
+ @classmethod
53
+ def parse(cls, base_url: str, api_key: str | None = None) -> "Endpoint":
54
+ u = urlsplit(base_url if "://" in base_url else "http://" + base_url)
55
+ if u.scheme not in ("http", "https") or not u.hostname:
56
+ raise ValueError(f"base_url must be http(s)://host[:port][/prefix], got {base_url!r}")
57
+ prefix = u.path.rstrip("/")
58
+ if prefix.endswith("/v1"):
59
+ prefix = prefix[:-3]
60
+ port = u.port or (443 if u.scheme == "https" else 80)
61
+ return cls(u.scheme, u.hostname, port, prefix, api_key)
62
+
63
+ def path(self, api: str) -> str:
64
+ return f"{self.prefix}/v1/chat/completions" if api == "chat" else f"{self.prefix}/v1/completions"
65
+
66
+ def is_local(self) -> bool:
67
+ return self.host in ("localhost", "127.0.0.1", "::1")
68
+
69
+
70
+ def scrub(text: str | None, ep: Endpoint) -> str | None:
71
+ """``text`` without the server's host name, IP addresses or port (error strings go into evidence)."""
72
+ if not text:
73
+ return text
74
+ out = re.sub(rf"(?<![A-Za-z0-9.-]){re.escape(ep.host)}(?![A-Za-z0-9-])", "<server>", text, flags=re.IGNORECASE)
75
+ out = _IPV4.sub("<addr>", out)
76
+ out = _IPV6.sub("<addr>", out)
77
+ return _PORT_AFTER.sub(r"\1\2\3<port>", out)
78
+
79
+
80
+ @dataclass
81
+ class Event:
82
+ t_ns: int
83
+ n_ids: int | None # token ids in this event (None: the server sent no ids)
84
+ n_chars: int
85
+
86
+
87
+ @dataclass
88
+ class RequestResult:
89
+ id: str
90
+ bucket: str
91
+ salt: str | None = None # unique prefix tag added to the prompt, if any
92
+ ok: bool = False
93
+ error: str | None = None
94
+ status: int | None = None
95
+ t_scheduled_ns: int | None = None
96
+ t_send_ns: int = 0
97
+ t_connected_ns: int | None = None
98
+ t_headers_ns: int | None = None
99
+ t_first_ns: int | None = None
100
+ t_last_ns: int | None = None
101
+ t_done_ns: int | None = None
102
+ t_end_ns: int = 0
103
+ events: list = field(default_factory=list) # token-bearing Events only
104
+ text: str = ""
105
+ reasoning: str = ""
106
+ token_ids: list | None = None
107
+ ids_source: str | None = None
108
+ finish_reason: str | None = None
109
+ usage: dict | None = None
110
+ n_sse_events: int = 0
111
+ # filled by count_tokens()
112
+ n_tokens: int | None = None
113
+ token_count_method: str | None = None
114
+ count_mismatch: str | None = None
115
+ n_ids: int | None = None # streamed ids, when the server sent any
116
+ n_usage: int | None = None # usage.completion_tokens, when the server sent it
117
+ # the exact JSON body sent (set by the caller; written to inputs/ for the gates)
118
+ request_body: dict | None = field(default=None, repr=False)
119
+
120
+ def to_json(self) -> dict:
121
+ d = asdict(self)
122
+ d["events"] = [[e.t_ns, e.n_ids, e.n_chars] for e in self.events]
123
+ d.pop("request_body", None)
124
+ return d
125
+
126
+
127
+ # --------------------------------------------------------------------------- SSE parsing
128
+
129
+ class _Stream:
130
+ """Accumulates one response's SSE events into a RequestResult."""
131
+
132
+ def __init__(self, res: RequestResult):
133
+ self.res = res
134
+ self.buf = b""
135
+ self.event_name = None
136
+ self.data_lines: list[str] = []
137
+ self.done = False
138
+ self._ids: list[int] = []
139
+ self._ids_seen = False
140
+ self._ids_via = None
141
+ self._sglext_ids = None
142
+
143
+ def feed(self, data: bytes, t_ns: int) -> None:
144
+ self.buf += data
145
+ while not self.done:
146
+ i = self.buf.find(b"\n")
147
+ if i < 0:
148
+ return
149
+ line, self.buf = self.buf[:i].rstrip(b"\r").decode("utf-8", "replace"), self.buf[i + 1:]
150
+ if line == "":
151
+ self._dispatch(t_ns)
152
+ elif line.startswith(":"):
153
+ continue # SSE comment / keep-alive
154
+ elif line.startswith("data:"):
155
+ value = line[5:]
156
+ self.data_lines.append(value[1:] if value.startswith(" ") else value) # SSE: drop one space
157
+ elif line.startswith("event:"):
158
+ self.event_name = line[6:].strip()
159
+
160
+ def close(self, t_ns: int) -> None:
161
+ if self.buf.strip(): # a last event without its blank line
162
+ self.feed(b"\n\n", t_ns)
163
+ elif self.data_lines:
164
+ self._dispatch(t_ns)
165
+
166
+ def _dispatch(self, t_ns: int) -> None:
167
+ if not self.data_lines:
168
+ self.event_name = None
169
+ return
170
+ payload = "\n".join(self.data_lines)
171
+ name, self.data_lines, self.event_name = self.event_name, [], None
172
+ res = self.res
173
+ res.n_sse_events += 1
174
+ if payload.strip() == "[DONE]":
175
+ self.done = True
176
+ res.t_done_ns = t_ns
177
+ return
178
+ try:
179
+ obj = json.loads(payload)
180
+ except ValueError:
181
+ res.error = res.error or f"unparseable SSE data: {payload[:200]!r}"
182
+ return
183
+ if not isinstance(obj, dict):
184
+ return
185
+ if name == "sglext_ids" or isinstance(obj.get("sglext"), dict):
186
+ ext = obj if name == "sglext_ids" else obj["sglext"]
187
+ out = ext.get("output_ids")
188
+ if isinstance(out, list) and out and isinstance(out[0], list):
189
+ self._sglext_ids = [int(x) for x in out[0]]
190
+ if obj.get("error") and not obj.get("choices"):
191
+ err = obj["error"]
192
+ res.error = res.error or ("server error in stream: " + (err.get("message") if isinstance(err, dict)
193
+ else str(err))[:500])
194
+ return
195
+ if isinstance(obj.get("usage"), dict):
196
+ res.usage = obj["usage"]
197
+ for c in obj.get("choices") or []:
198
+ if not isinstance(c, dict) or c.get("index", 0) != 0:
199
+ continue
200
+ text, reasoning = "", ""
201
+ delta = c.get("delta")
202
+ if isinstance(delta, dict): # chat
203
+ text = delta.get("content") or ""
204
+ reasoning = delta.get("reasoning_content") or delta.get("reasoning") or ""
205
+ if delta.get("tool_calls"):
206
+ text = text or json.dumps(delta["tool_calls"])
207
+ elif isinstance(c.get("text"), str): # completions
208
+ text = c["text"]
209
+ ids, via = _ids_in_choice(c)
210
+ if ids is not None:
211
+ self._ids_seen = True
212
+ self._ids_via = self._ids_via or via
213
+ self._ids.extend(ids)
214
+ res.text += text
215
+ res.reasoning += reasoning
216
+ n_chars = len(text) + len(reasoning)
217
+ if (ids is not None and len(ids) > 0) or (ids is None and n_chars > 0):
218
+ ev = Event(t_ns, len(ids) if ids is not None else None, n_chars)
219
+ res.events.append(ev)
220
+ if res.t_first_ns is None:
221
+ res.t_first_ns = t_ns
222
+ res.t_last_ns = t_ns
223
+ if c.get("finish_reason"):
224
+ res.finish_reason = c["finish_reason"]
225
+
226
+ def finish(self) -> None:
227
+ res = self.res
228
+ if self._ids_seen:
229
+ res.token_ids, res.ids_source = list(self._ids), self._ids_via
230
+ elif self._sglext_ids is not None:
231
+ res.token_ids, res.ids_source = self._sglext_ids, "sglext_output_ids"
232
+
233
+
234
+ def _ids_in_choice(c: dict) -> tuple[list[int] | None, str | None]:
235
+ """Generated token ids carried by one streamed choice, and where they were found."""
236
+ tid = c.get("token_ids")
237
+ if isinstance(tid, list):
238
+ return [int(x) for x in tid], "choice_token_ids"
239
+ lp = c.get("logprobs")
240
+ if isinstance(lp, dict):
241
+ content = lp.get("content")
242
+ if isinstance(content, list): # chat logprobs
243
+ out = []
244
+ for e in content:
245
+ if not isinstance(e, dict):
246
+ return None, None
247
+ if isinstance(e.get("id"), int):
248
+ out.append(e["id"])
249
+ elif isinstance(e.get("token"), str) and _TOKEN_ID.match(e["token"]):
250
+ out.append(int(_TOKEN_ID.match(e["token"]).group(1)))
251
+ else:
252
+ return None, None
253
+ return out, "logprobs_ids"
254
+ tokens = lp.get("tokens")
255
+ if isinstance(tokens, list) and all(isinstance(t, str) and _TOKEN_ID.match(t) for t in tokens):
256
+ return [int(_TOKEN_ID.match(t).group(1)) for t in tokens], "logprobs_ids"
257
+ return None, None
258
+
259
+
260
+ # --------------------------------------------------------------------------- HTTP
261
+
262
+ def _request_bytes(ep: Endpoint, path: str, body: bytes, method: str = "POST") -> bytes:
263
+ host = ep.host if ":" not in ep.host else f"[{ep.host}]"
264
+ lines = [f"{method} {path} HTTP/1.1", f"Host: {host}:{ep.port}", "User-Agent: inferencekit-bench-serve",
265
+ "Accept: text/event-stream", "Connection: close"]
266
+ if method == "POST":
267
+ lines += ["Content-Type: application/json", f"Content-Length: {len(body)}"]
268
+ if ep.api_key:
269
+ lines.append(f"Authorization: Bearer {ep.api_key}")
270
+ return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + body
271
+
272
+
273
+ async def _open(ep: Endpoint):
274
+ ctx = ssl.create_default_context() if ep.scheme == "https" else None
275
+ return await asyncio.open_connection(ep.host, ep.port, ssl=ctx, limit=1 << 20)
276
+
277
+
278
+ async def _read_head(reader) -> tuple[int, dict]:
279
+ status_line = await reader.readline()
280
+ if not status_line:
281
+ raise ConnectionError("server closed the connection before responding")
282
+ parts = status_line.decode("latin-1").split(" ", 2)
283
+ if len(parts) < 2 or not parts[0].startswith("HTTP/"):
284
+ raise ConnectionError(f"not an HTTP response: {status_line[:80]!r}")
285
+ status = int(parts[1])
286
+ headers = {}
287
+ while True:
288
+ line = await reader.readline()
289
+ if line in (b"\r\n", b"\n", b""):
290
+ break
291
+ k, _, v = line.decode("latin-1").partition(":")
292
+ headers[k.strip().lower()] = v.strip()
293
+ return status, headers
294
+
295
+
296
+ async def _body_chunks(reader, headers: dict):
297
+ """Yield (bytes, perf_counter_ns at arrival) for a chunked, sized or close-delimited body."""
298
+ if "chunked" in headers.get("transfer-encoding", "").lower():
299
+ while True:
300
+ size_line = await reader.readline()
301
+ if not size_line:
302
+ return
303
+ size = int(size_line.split(b";")[0].strip() or b"0", 16)
304
+ if size == 0:
305
+ return
306
+ data = await reader.readexactly(size + 2)
307
+ yield data[:-2], time.perf_counter_ns()
308
+ elif "content-length" in headers:
309
+ left = int(headers["content-length"])
310
+ while left > 0:
311
+ data = await reader.read(min(left, 1 << 16))
312
+ if not data:
313
+ return
314
+ left -= len(data)
315
+ yield data, time.perf_counter_ns()
316
+ else:
317
+ while True:
318
+ data = await reader.read(1 << 16)
319
+ if not data:
320
+ return
321
+ yield data, time.perf_counter_ns()
322
+
323
+
324
+ async def _close(writer) -> None:
325
+ writer.close()
326
+ with contextlib.suppress(Exception):
327
+ await asyncio.wait_for(writer.wait_closed(), 1.0)
328
+
329
+
330
+ async def stream(ep: Endpoint, api: str, body: dict, *, rid: str, bucket: str, timeout_s: float = 600.0,
331
+ t_scheduled_ns: int | None = None) -> RequestResult:
332
+ """Send one streamed request and time it. Never raises: failures are in ``result.error``."""
333
+ res = RequestResult(id=rid, bucket=bucket, t_scheduled_ns=t_scheduled_ns)
334
+ payload = _request_bytes(ep, ep.path(api), json.dumps(body, ensure_ascii=False).encode("utf-8"))
335
+ st = _Stream(res)
336
+ writer = None
337
+
338
+ async def run():
339
+ nonlocal writer
340
+ reader, writer = await _open(ep)
341
+ res.t_connected_ns = time.perf_counter_ns()
342
+ writer.write(payload)
343
+ await writer.drain()
344
+ res.status, headers = await _read_head(reader)
345
+ res.t_headers_ns = time.perf_counter_ns()
346
+ if res.status != 200:
347
+ raw = b""
348
+ async for data, _ in _body_chunks(reader, headers):
349
+ raw += data
350
+ if len(raw) > MAX_ERROR_BODY:
351
+ break
352
+ res.error = f"HTTP {res.status}: {raw[:MAX_ERROR_BODY].decode('utf-8', 'replace')}"
353
+ return
354
+ async for data, t in _body_chunks(reader, headers):
355
+ st.feed(data, t)
356
+ if st.done:
357
+ break
358
+ st.close(time.perf_counter_ns())
359
+
360
+ res.t_send_ns = time.perf_counter_ns()
361
+ try:
362
+ await asyncio.wait_for(run(), timeout_s)
363
+ except asyncio.TimeoutError:
364
+ res.error = f"timeout after {timeout_s:g} s"
365
+ except (OSError, ConnectionError, asyncio.IncompleteReadError, ValueError, UnicodeError) as e:
366
+ res.error = f"{type(e).__name__}: {e}"[:500]
367
+ finally:
368
+ res.t_end_ns = time.perf_counter_ns()
369
+ if writer is not None:
370
+ await _close(writer)
371
+ st.finish()
372
+ if res.error is None and not st.done and res.finish_reason is None:
373
+ # The connection closed before the server said the generation was over: a dropped stream.
374
+ res.error = "stream ended early (no finish_reason and no [DONE])"
375
+ if res.t_done_ns is None and res.error is None:
376
+ res.t_done_ns = res.t_end_ns
377
+ if res.error is None and res.t_first_ns is None:
378
+ res.error = "stream ended without any generated token"
379
+ res.ok = res.error is None
380
+ res.error = scrub(res.error, ep)
381
+ return res
382
+
383
+
384
+ async def get_json(ep: Endpoint, path: str, timeout_s: float = 10.0) -> tuple[int, object]:
385
+ """GET a small JSON document (e.g. /v1/models). Returns (status, parsed body or text)."""
386
+
387
+ async def run():
388
+ reader, writer = await _open(ep)
389
+ try:
390
+ writer.write(_request_bytes(ep, ep.prefix + path, b"", method="GET"))
391
+ await writer.drain()
392
+ status, headers = await _read_head(reader)
393
+ raw = b""
394
+ async for data, _ in _body_chunks(reader, headers):
395
+ raw += data
396
+ try:
397
+ return status, json.loads(raw)
398
+ except ValueError:
399
+ return status, raw[:MAX_ERROR_BODY].decode("utf-8", "replace")
400
+ finally:
401
+ await _close(writer)
402
+
403
+ return await asyncio.wait_for(run(), timeout_s)
404
+
405
+
406
+ # --------------------------------------------------------------------------- token counting
407
+
408
+ def count_tokens(res: RequestResult, tokenizer=None) -> None:
409
+ """Fill n_tokens and token_count_method, in the contract's order of preference:
410
+ streamed token ids > usage.completion_tokens > re-tokenizing the text > counting SSE events.
411
+
412
+ When the server sends both ids and usage and they disagree, the smaller count is used
413
+ (method ``min(stream_token_ids:...,usage)``) and both are kept (n_ids, n_usage, count_mismatch):
414
+ a server that streams padded or duplicated ids must not be able to raise its own decode speed.
415
+
416
+ ``tokenizer`` is a callable text -> list of ids (an HF tokenizer's ``encode`` without special
417
+ tokens). The last resort, counting token-bearing events, is only right when a server sends exactly
418
+ one token per event; the method name says so.
419
+ """
420
+ usage_n = None
421
+ if isinstance(res.usage, dict) and isinstance(res.usage.get("completion_tokens"), int):
422
+ usage_n = res.usage["completion_tokens"]
423
+ res.n_usage = usage_n
424
+ res.n_ids = len(res.token_ids) if res.token_ids is not None else None
425
+ if res.token_ids is not None and (res.token_ids or usage_n in (None, 0)):
426
+ res.n_tokens, res.token_count_method = len(res.token_ids), f"stream_token_ids:{res.ids_source}"
427
+ if usage_n is not None and usage_n != len(res.token_ids):
428
+ res.count_mismatch = f"{len(res.token_ids)} streamed ids vs usage.completion_tokens={usage_n}"
429
+ res.n_tokens = min(len(res.token_ids), usage_n)
430
+ res.token_count_method = f"min(stream_token_ids:{res.ids_source},usage)"
431
+ elif usage_n is not None:
432
+ res.n_tokens, res.token_count_method = usage_n, "usage"
433
+ elif tokenizer is not None and res.text + res.reasoning:
434
+ res.n_tokens, res.token_count_method = len(tokenizer(res.reasoning + res.text)), "retokenize"
435
+ elif res.events:
436
+ res.n_tokens, res.token_count_method = len(res.events), "sse_events"
437
+
438
+
439
+ def api_key_from_env(name: str | None) -> str | None:
440
+ """The API key is read from an environment variable and never stored or printed."""
441
+ if not name:
442
+ return None
443
+ value = os.environ.get(name)
444
+ if not value:
445
+ raise ValueError(f"environment variable {name} is not set")
446
+ return value