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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|