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,203 @@
|
|
|
1
|
+
"""Numbers from timed requests: per request, per bucket (single-user mode) and per load level.
|
|
2
|
+
|
|
3
|
+
Per request (all from client timestamps, see client.py):
|
|
4
|
+
ttft_ms t_first - t_send
|
|
5
|
+
e2e_ms t_done - t_send
|
|
6
|
+
decode_tok_s (n_tokens - k_first) / (t_last - t_first), k_first = tokens in the first token event
|
|
7
|
+
(1 when the server sent no ids). "Tokens after the first, over time after the first",
|
|
8
|
+
counted from ids so a server that packs several tokens into one event is not undercounted.
|
|
9
|
+
tpot_ms (t_last - t_first) / (n_tokens - k_first) = 1000 / decode_tok_s
|
|
10
|
+
itl_ms gaps between consecutive token events (per event, as most serving benchmarks report)
|
|
11
|
+
|
|
12
|
+
Percentiles are nearest-rank (always an observed value), the same convention the bootstrap uses, so a
|
|
13
|
+
point estimate and its CI agree.
|
|
14
|
+
|
|
15
|
+
Single-user summaries: value = median (and p95) over every request of a bucket; 95% CI by cluster
|
|
16
|
+
bootstrap with the *round* as the unit (stats.bootstrap_ci), because requests inside one round share
|
|
17
|
+
the server's state and are not independent.
|
|
18
|
+
|
|
19
|
+
Load-level summaries: p50/p95/p99 over the level's requests; the p95 CI is a plain bootstrap over
|
|
20
|
+
requests (under load, requests interact, so treat it as optimistic; the record says so).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from inferencekit import evidence as ev
|
|
26
|
+
from inferencekit import stats
|
|
27
|
+
from inferencekit.serve.client import RequestResult
|
|
28
|
+
|
|
29
|
+
SPEED_METRICS = (("ttft_ms", "ms"), ("tpot_ms", "ms"), ("decode_tok_s", "tok/s"), ("e2e_ms", "ms"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pct(values: list[float], p: float) -> float | None:
|
|
33
|
+
"""Nearest-rank percentile (an observed value), as inside stats.bootstrap_ci."""
|
|
34
|
+
return stats.nearest_rank(sorted(values), p)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ms(a: int | None, b: int | None) -> float | None:
|
|
38
|
+
return (a - b) / 1e6 if a is not None and b is not None else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def request_row(res: RequestResult, **extra) -> dict:
|
|
42
|
+
"""One request's numbers (no text): a row of requests.jsonl."""
|
|
43
|
+
ttft = _ms(res.t_first_ns, res.t_send_ns)
|
|
44
|
+
e2e = _ms(res.t_done_ns if res.t_done_ns is not None else res.t_end_ns, res.t_send_ns)
|
|
45
|
+
n = res.n_tokens
|
|
46
|
+
k_first = res.events[0].n_ids if res.events and res.events[0].n_ids else 1
|
|
47
|
+
span = _ms(res.t_last_ns, res.t_first_ns)
|
|
48
|
+
after = n - k_first if n is not None else None
|
|
49
|
+
decode = tpot = None
|
|
50
|
+
if res.ok and after and after > 0 and span and span > 0:
|
|
51
|
+
decode = after / (span / 1000.0)
|
|
52
|
+
tpot = span / after
|
|
53
|
+
per_event = [e.n_ids for e in res.events if e.n_ids]
|
|
54
|
+
row = {"id": res.id, "bucket": res.bucket, "salt": res.salt, "ok": res.ok, "error": res.error, "status": res.status,
|
|
55
|
+
"ttft_ms": ttft if res.ok else None, "tpot_ms": tpot, "decode_tok_s": decode,
|
|
56
|
+
"e2e_ms": e2e if res.ok else None, "connect_ms": _ms(res.t_connected_ns, res.t_send_ns),
|
|
57
|
+
"n_tokens": n, "token_count_method": res.token_count_method, "count_mismatch": res.count_mismatch,
|
|
58
|
+
"n_ids": res.n_ids, "n_usage": res.n_usage,
|
|
59
|
+
"finish_reason": res.finish_reason, "n_token_events": len(res.events),
|
|
60
|
+
"tokens_per_event_max": max(per_event) if per_event else None,
|
|
61
|
+
"send_lag_ms": _ms(res.t_send_ns, res.t_scheduled_ns), "t_send_ns": res.t_send_ns,
|
|
62
|
+
"t_end_ns": res.t_end_ns}
|
|
63
|
+
row.update(extra)
|
|
64
|
+
return row
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def itl_ms(res: RequestResult) -> list[float]:
|
|
68
|
+
ev_ = res.events
|
|
69
|
+
return [(b.t_ns - a.t_ns) / 1e6 for a, b in zip(ev_, ev_[1:])]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def timings(res: RequestResult) -> dict:
|
|
73
|
+
"""The per-event timeline stored with each output (ms after t_send, and ids per event)."""
|
|
74
|
+
return {"t_send_ns": res.t_send_ns,
|
|
75
|
+
"event_ms": [round((e.t_ns - res.t_send_ns) / 1e6, 4) for e in res.events],
|
|
76
|
+
"event_token_ids": [e.n_ids for e in res.events]}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- single-user mode
|
|
80
|
+
|
|
81
|
+
def bucket_metrics(rows: list[dict], buckets: list[str], resamples: int, seed: int = 0) -> tuple[list[dict], dict]:
|
|
82
|
+
"""Evidence metrics per bucket (and 'all'): median and p95, with CIs over rounds. Also a plain dict."""
|
|
83
|
+
out, table = [], {}
|
|
84
|
+
for subject in list(buckets) + ["all"]:
|
|
85
|
+
sel = [r for r in rows if r["ok"] and (subject == "all" or r["bucket"] == subject)]
|
|
86
|
+
table[subject] = {"n_requests": len(sel)}
|
|
87
|
+
for name, unit in SPEED_METRICS:
|
|
88
|
+
vals = [(r[name], r["round"]) for r in sel if r.get(name) is not None]
|
|
89
|
+
values, units = [v for v, _ in vals], [u for _, u in vals]
|
|
90
|
+
if not values:
|
|
91
|
+
out.append(ev.metric(name, None, unit, "median", n=0, subject=subject))
|
|
92
|
+
table[subject][name] = None
|
|
93
|
+
continue
|
|
94
|
+
ci = stats.bootstrap_ci(values, units, ps=(50, 95), resamples=resamples, seed=seed)
|
|
95
|
+
med, p95 = pct(values, 50), pct(values, 95)
|
|
96
|
+
out.append(ev.metric(name, med, unit, "median", n=len(values), ci95=ci[50], subject=subject))
|
|
97
|
+
out.append(ev.metric(name, p95, unit, "p95", n=len(values), ci95=ci[95], subject=subject))
|
|
98
|
+
table[subject][name] = {"median": med, "p95": p95, "ci95_median": list(ci[50]),
|
|
99
|
+
"ci95_p95": list(ci[95]), "n": len(values), "rounds": len(set(units))}
|
|
100
|
+
return out, table
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def round_rows(rows: list[dict], round_walltimes: dict[int, tuple[float, float]]) -> list[dict]:
|
|
104
|
+
"""One row per round (medians over the round's requests), in the row shape of bench decode records
|
|
105
|
+
(``compare`` reads that shape; it accepts serve_benchmark records only once that lands at merge)."""
|
|
106
|
+
out = []
|
|
107
|
+
for r, (t0, t1) in sorted(round_walltimes.items()):
|
|
108
|
+
sel = [x for x in rows if x["round"] == r and x["ok"]]
|
|
109
|
+
row = {"index": r, "t_start": t0, "t_end": t1, "n_requests": len(sel),
|
|
110
|
+
"n_failed": sum(1 for x in rows if x["round"] == r and not x["ok"])}
|
|
111
|
+
for name, key in (("decode_tok_s", "decode_tok_s"), ("ttft_ms", "ttft_ms"), ("tpot_ms", "tpot_ms"),
|
|
112
|
+
("total_ms", "e2e_ms")):
|
|
113
|
+
vals = [x[key] for x in sel if x.get(key) is not None]
|
|
114
|
+
row[name] = pct(vals, 50) if vals else None
|
|
115
|
+
out.append(row)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --------------------------------------------------------------------------- load mode
|
|
120
|
+
|
|
121
|
+
def _pcts(values: list[float]) -> dict:
|
|
122
|
+
if not values:
|
|
123
|
+
return {"p50": None, "p95": None, "p99": None, "n": 0}
|
|
124
|
+
return {"p50": pct(values, 50), "p95": pct(values, 95), "p99": pct(values, 99), "n": len(values)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def meets_slo(row: dict, slo: dict) -> bool:
|
|
128
|
+
"""Per-request SLO attainment (for goodput): succeeded, TTFT and TPOT inside their targets."""
|
|
129
|
+
if not row["ok"]:
|
|
130
|
+
return False
|
|
131
|
+
if slo.get("ttft_p95_ms") is not None and (row["ttft_ms"] is None or row["ttft_ms"] > slo["ttft_p95_ms"]):
|
|
132
|
+
return False
|
|
133
|
+
if slo.get("tpot_p95_ms") is not None and row["tpot_ms"] is not None and row["tpot_ms"] > slo["tpot_p95_ms"]:
|
|
134
|
+
return False
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def level_summary(rows: list[dict], itls: list[float], slo: dict, gpu_count: int, resamples: int = 2000,
|
|
139
|
+
seed: int = 0) -> dict:
|
|
140
|
+
"""One load level: latency percentiles, failures, goodput, throughput and whether the SLO held."""
|
|
141
|
+
ok = [r for r in rows if r["ok"]]
|
|
142
|
+
t0 = min((r["t_send_ns"] for r in rows), default=0)
|
|
143
|
+
t1 = max((r["t_end_ns"] for r in rows), default=0)
|
|
144
|
+
window_s = (t1 - t0) / 1e9 if rows else 0.0
|
|
145
|
+
out_tokens = sum(r["n_tokens"] or 0 for r in ok)
|
|
146
|
+
s = {"n_requests": len(rows), "n_ok": len(ok), "n_failed": len(rows) - len(ok), "window_s": window_s,
|
|
147
|
+
"ttft_ms": _pcts([r["ttft_ms"] for r in ok if r["ttft_ms"] is not None]),
|
|
148
|
+
"tpot_ms": _pcts([r["tpot_ms"] for r in ok if r["tpot_ms"] is not None]),
|
|
149
|
+
"e2e_ms": _pcts([r["e2e_ms"] for r in ok if r["e2e_ms"] is not None]),
|
|
150
|
+
"itl_ms": _pcts(itls),
|
|
151
|
+
"output_tokens": out_tokens,
|
|
152
|
+
"output_tok_s": out_tokens / window_s if window_s > 0 else None,
|
|
153
|
+
"output_tok_s_per_gpu": out_tokens / window_s / max(1, gpu_count) if window_s > 0 else None,
|
|
154
|
+
"goodput_rps": sum(1 for r in rows if meets_slo(r, slo)) / window_s if window_s > 0 else None,
|
|
155
|
+
"achieved_rps": len(ok) / window_s if window_s > 0 else None,
|
|
156
|
+
# Little's law: time-averaged requests in flight over the window
|
|
157
|
+
"mean_inflight": sum((r["t_end_ns"] - r["t_send_ns"]) for r in rows) / 1e9 / window_s if window_s else None,
|
|
158
|
+
"send_lag_ms": _pcts([r["send_lag_ms"] for r in rows if r.get("send_lag_ms") is not None])}
|
|
159
|
+
for name in ("ttft_ms", "tpot_ms"):
|
|
160
|
+
vals = [r[name] for r in ok if r[name] is not None]
|
|
161
|
+
s[name]["ci95_p95"] = list(stats.bootstrap_ci(vals, ps=(95,), resamples=resamples, seed=seed)[95])
|
|
162
|
+
s["slo_checks"] = slo_checks(s, slo)
|
|
163
|
+
s["slo_ok"] = all(c["ok"] for c in s["slo_checks"].values()) if s["slo_checks"] else None
|
|
164
|
+
return s
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def slo_checks(s: dict, slo: dict) -> dict:
|
|
168
|
+
checks = {}
|
|
169
|
+
if slo.get("ttft_p95_ms") is not None:
|
|
170
|
+
v = s["ttft_ms"]["p95"]
|
|
171
|
+
checks["ttft_p95_ms"] = {"value": v, "target": slo["ttft_p95_ms"], "ok": v is not None and v <= slo["ttft_p95_ms"]}
|
|
172
|
+
if slo.get("tpot_p95_ms") is not None:
|
|
173
|
+
v = s["tpot_ms"]["p95"]
|
|
174
|
+
checks["tpot_p95_ms"] = {"value": v, "target": slo["tpot_p95_ms"], "ok": v is not None and v <= slo["tpot_p95_ms"]}
|
|
175
|
+
if checks: # failures only count when an SLO was given
|
|
176
|
+
mf = slo.get("max_fail") or 0
|
|
177
|
+
checks["failed"] = {"value": s["n_failed"], "target": mf, "ok": s["n_failed"] <= mf and s["n_ok"] > 0}
|
|
178
|
+
return checks
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def n_star(levels: list[dict]) -> tuple[float | None, str]:
|
|
182
|
+
"""Highest level at which the SLO holds at that level and at every lower one.
|
|
183
|
+
|
|
184
|
+
Returns (level or None, reason). A level that passes above a failing one does not count.
|
|
185
|
+
"""
|
|
186
|
+
if not levels:
|
|
187
|
+
return None, "no load levels ran"
|
|
188
|
+
best = None
|
|
189
|
+
for lv in sorted(levels, key=lambda x: x["level"]):
|
|
190
|
+
if lv["summary"].get("slo_ok") is None:
|
|
191
|
+
return None, "no SLO given"
|
|
192
|
+
if not lv["summary"]["slo_ok"]:
|
|
193
|
+
failed = [k for k, c in lv["summary"]["slo_checks"].items() if not c["ok"]]
|
|
194
|
+
return best, f"SLO first fails at level {lv['level']:g} ({', '.join(failed)})"
|
|
195
|
+
best = lv["level"]
|
|
196
|
+
return best, "SLO holds at every level tried: the true N* may be above the grid"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def usd_per_1m_tokens(tok_s: float | None, price_usd_h: float | None, gpu_count: int = 1) -> float | None:
|
|
200
|
+
"""$ per 1M output tokens = hourly price of the GPUs / tokens produced per hour x 1e6."""
|
|
201
|
+
if not tok_s or not price_usd_h or tok_s <= 0:
|
|
202
|
+
return None
|
|
203
|
+
return price_usd_h * max(1, gpu_count) / (tok_s * 3600.0) * 1e6
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Per-engine request profiles: how to ask each OpenAI-compatible server for an exact output length,
|
|
2
|
+
streamed token ids and a usage block, using only fields that server documents.
|
|
3
|
+
|
|
4
|
+
Why it matters: a fair speed comparison needs every engine to generate the same number of tokens
|
|
5
|
+
(otherwise an engine that stops early at EOS looks faster), and an honest decode speed needs a token
|
|
6
|
+
count that does not depend on how many tokens a server packs into one SSE event.
|
|
7
|
+
|
|
8
|
+
Sources (checked 13 Oct 2026; fields can move between releases, so the client parses every known
|
|
9
|
+
location and the record says which one was used):
|
|
10
|
+
vLLM vllm/entrypoints/openai/chat_completion/protocol.py (ChatCompletionRequest: ignore_eos,
|
|
11
|
+
min_tokens, return_token_ids, return_tokens_as_token_ids, stream_options;
|
|
12
|
+
ChatCompletionResponseStreamChoice.token_ids "for tracing the tokens") and
|
|
13
|
+
docs.vllm.ai/en/v0.12.0/serving/openai_compatible_server/ ("Extra parameters": extra
|
|
14
|
+
fields are merged into the JSON body; return_token_ids needs vLLM >= 0.10.2).
|
|
15
|
+
SGLang python/sglang/srt/entrypoints/openai/protocol.py (ignore_eos, min_tokens, stream_options,
|
|
16
|
+
SglExt.output_ids) and serving_chat.py (return_token_ids is rejected when stream=true, so it
|
|
17
|
+
is not requested; usage arrives in a final chunk when stream_options.include_usage is set).
|
|
18
|
+
llama.cpp tools/server/README.md (ignore_eos and other /completion fields are accepted on the
|
|
19
|
+
OpenAI-compatible endpoints) and tools/server/server-task.cpp (logprob entries carry "id";
|
|
20
|
+
usage arrives in a trailing chunk when include_usage is set). llama-server has no
|
|
21
|
+
min_tokens. Asking for logprobs makes it compute token probabilities every step, which
|
|
22
|
+
costs decode time, so ids are requested only with --token-ids logprobs.
|
|
23
|
+
TRT-LLM tensorrt_llm/serve/openai_protocol.py (ChatCompletionRequest / CompletionRequest:
|
|
24
|
+
ignore_eos, min_tokens, stream_options{include_usage}; CompletionResponseStreamChoice.token_ids;
|
|
25
|
+
no request field to ask for chat token ids).
|
|
26
|
+
generic plain OpenAI fields only (max_tokens, stream, stream_options.include_usage).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
|
|
33
|
+
ENGINE_KINDS = ("vllm", "sglang", "llamacpp", "trtllm", "generic")
|
|
34
|
+
APIS = ("chat", "completions")
|
|
35
|
+
TOKEN_ID_MODES = ("auto", "logprobs", "off")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Profile:
|
|
40
|
+
name: str
|
|
41
|
+
# Body fields that force exactly max_tokens; "{n}" is replaced by max_tokens.
|
|
42
|
+
exact_length: dict = field(default_factory=dict)
|
|
43
|
+
# Body fields that make the server stream generated token ids at (near) zero cost, per API.
|
|
44
|
+
ids_free: dict = field(default_factory=dict)
|
|
45
|
+
# Body fields that stream token ids through logprobs (may slow decode), per API.
|
|
46
|
+
ids_logprobs: dict = field(default_factory=dict)
|
|
47
|
+
note: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
PROFILES: dict[str, Profile] = {
|
|
51
|
+
"vllm": Profile(
|
|
52
|
+
"vllm",
|
|
53
|
+
exact_length={"ignore_eos": True, "min_tokens": "{n}"},
|
|
54
|
+
ids_free={"chat": {"return_token_ids": True}, "completions": {"return_token_ids": True}},
|
|
55
|
+
ids_logprobs={"chat": {"logprobs": True, "top_logprobs": 0, "return_tokens_as_token_ids": True},
|
|
56
|
+
"completions": {"logprobs": 0, "return_tokens_as_token_ids": True}},
|
|
57
|
+
note="token ids from choices[].token_ids (return_token_ids, vLLM >= 0.10.2)"),
|
|
58
|
+
"sglang": Profile(
|
|
59
|
+
"sglang",
|
|
60
|
+
exact_length={"ignore_eos": True, "min_tokens": "{n}"},
|
|
61
|
+
note="return_token_ids is not supported with streaming; tokens counted from usage unless the "
|
|
62
|
+
"server adds sglext.output_ids"),
|
|
63
|
+
"llamacpp": Profile(
|
|
64
|
+
"llamacpp",
|
|
65
|
+
exact_length={"ignore_eos": True},
|
|
66
|
+
ids_logprobs={"chat": {"logprobs": True, "top_logprobs": 1}, "completions": {"logprobs": 1}},
|
|
67
|
+
note="no min_tokens; ids only through logprobs (costs decode time), else usage"),
|
|
68
|
+
"trtllm": Profile(
|
|
69
|
+
"trtllm",
|
|
70
|
+
exact_length={"ignore_eos": True, "min_tokens": "{n}"},
|
|
71
|
+
note="completions stream choices may carry token_ids; chat has none, so usage is used"),
|
|
72
|
+
"generic": Profile("generic", note="standard OpenAI fields only: no exact-length control"),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_body(api: str, engine_kind: str, model: str, messages: list[dict], max_tokens: int, *,
|
|
77
|
+
token_ids: str = "auto", exact_length: bool = True, extra: dict | None = None) -> tuple[dict, dict]:
|
|
78
|
+
"""The JSON body for one streamed request, and a description of what was asked for.
|
|
79
|
+
|
|
80
|
+
Greedy decoding (temperature 0) by default so outputs are comparable across engines; ``extra``
|
|
81
|
+
(the user's --extra-body) is merged last and can override anything.
|
|
82
|
+
"""
|
|
83
|
+
prof = PROFILES[engine_kind]
|
|
84
|
+
body: dict = {"model": model}
|
|
85
|
+
if api == "chat":
|
|
86
|
+
body["messages"] = list(messages)
|
|
87
|
+
else:
|
|
88
|
+
body["prompt"] = "\n\n".join(str(m.get("content", "")) for m in messages)
|
|
89
|
+
body.update({"max_tokens": int(max_tokens), "temperature": 0.0, "stream": True,
|
|
90
|
+
"stream_options": {"include_usage": True}})
|
|
91
|
+
asked = {"exact_length": None, "token_ids": None}
|
|
92
|
+
if exact_length and prof.exact_length:
|
|
93
|
+
body.update({k: (int(max_tokens) if v == "{n}" else v) for k, v in prof.exact_length.items()})
|
|
94
|
+
asked["exact_length"] = sorted(prof.exact_length)
|
|
95
|
+
if token_ids != "off":
|
|
96
|
+
fields = prof.ids_free.get(api)
|
|
97
|
+
how = "free"
|
|
98
|
+
if not fields and token_ids == "logprobs":
|
|
99
|
+
fields, how = prof.ids_logprobs.get(api), "logprobs"
|
|
100
|
+
if fields:
|
|
101
|
+
body.update(fields)
|
|
102
|
+
asked["token_ids"] = {"via": how, "fields": sorted(fields)}
|
|
103
|
+
if extra:
|
|
104
|
+
body.update(extra)
|
|
105
|
+
return body, asked
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Read a prompt set: JSONL, one request per line (the format ``prompts build`` writes).
|
|
2
|
+
|
|
3
|
+
{"id": "short-0007", "bucket": "short", "messages": [{"role": "user", "content": "..."}],
|
|
4
|
+
"max_tokens": 256, "source": "dataset/license", "kind": "chat"}
|
|
5
|
+
|
|
6
|
+
This module only reads. Building and sealing sets is ``inferencekit prompts ...``. The file may
|
|
7
|
+
be gzipped (``.jsonl.gz``, as the shipped dev sets are). The set's identity is the sha256 of the
|
|
8
|
+
uncompressed JSONL bytes, the same hash its manifest carries, so a benchmark record can be
|
|
9
|
+
matched to the exact prompts it ran.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from inferencekit import prompts as _sets
|
|
20
|
+
from inferencekit.registry import OpError
|
|
21
|
+
|
|
22
|
+
BUCKETS = ("very_short", "short", "medium", "long")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Prompt:
|
|
27
|
+
id: str
|
|
28
|
+
bucket: str
|
|
29
|
+
messages: tuple # OpenAI chat messages (dicts); a tuple so the dataclass stays hashable
|
|
30
|
+
max_tokens: int | None
|
|
31
|
+
kind: str = "chat"
|
|
32
|
+
extra: dict = field(default_factory=dict, compare=False, hash=False)
|
|
33
|
+
|
|
34
|
+
def as_completion_prompt(self) -> str:
|
|
35
|
+
"""Plain text for /v1/completions: the message contents joined (no chat template)."""
|
|
36
|
+
return "\n\n".join(str(m.get("content", "")) for m in self.messages)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class PromptSet:
|
|
41
|
+
name: str
|
|
42
|
+
path: Path
|
|
43
|
+
sha256: str
|
|
44
|
+
prompts: tuple[Prompt, ...]
|
|
45
|
+
manifest: dict | None = None
|
|
46
|
+
|
|
47
|
+
def buckets(self) -> list[str]:
|
|
48
|
+
seen = []
|
|
49
|
+
for p in self.prompts:
|
|
50
|
+
if p.bucket not in seen:
|
|
51
|
+
seen.append(p.bucket)
|
|
52
|
+
return sorted(seen, key=lambda b: (BUCKETS.index(b) if b in BUCKETS else len(BUCKETS), b))
|
|
53
|
+
|
|
54
|
+
def identity(self) -> dict:
|
|
55
|
+
return {"name": self.name, "sha256": self.sha256}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load(path: str | Path, buckets: list[str] | None = None, limit_per_bucket: int | None = None) -> PromptSet:
|
|
59
|
+
"""Load and check a prompt set. ``buckets`` / ``limit_per_bucket`` select a subset (file order)."""
|
|
60
|
+
p = _sets.existing(path)
|
|
61
|
+
if not p.is_file():
|
|
62
|
+
raise OpError(f"prompt set {path} not found")
|
|
63
|
+
try:
|
|
64
|
+
raw = _sets.read_bytes(p)
|
|
65
|
+
text = raw.decode("utf-8")
|
|
66
|
+
except (OSError, ValueError) as e: # a truncated or corrupt .jsonl.gz, or not UTF-8
|
|
67
|
+
raise OpError(f"prompt set {p.name}: cannot read ({e})") from None
|
|
68
|
+
sha = hashlib.sha256(raw).hexdigest()
|
|
69
|
+
manifest = _manifest(p)
|
|
70
|
+
if manifest and manifest.get("sha256") and manifest["sha256"] != sha:
|
|
71
|
+
raise OpError(f"prompt set {p.name}: sha256 {sha[:12]}... does not match its manifest "
|
|
72
|
+
f"({str(manifest['sha256'])[:12]}...): the file changed after it was built")
|
|
73
|
+
prompts, ids, per_bucket = [], set(), {}
|
|
74
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
75
|
+
if not line.strip():
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
obj = json.loads(line)
|
|
79
|
+
except ValueError as e:
|
|
80
|
+
raise OpError(f"{p.name}:{n}: not JSON ({e})") from None
|
|
81
|
+
prompt = _parse(obj, f"{p.name}:{n}")
|
|
82
|
+
if prompt.id in ids:
|
|
83
|
+
raise OpError(f"{p.name}:{n}: duplicate id {prompt.id!r}")
|
|
84
|
+
ids.add(prompt.id)
|
|
85
|
+
if buckets and prompt.bucket not in buckets:
|
|
86
|
+
continue
|
|
87
|
+
k = per_bucket.get(prompt.bucket, 0)
|
|
88
|
+
if limit_per_bucket and k >= limit_per_bucket:
|
|
89
|
+
continue
|
|
90
|
+
per_bucket[prompt.bucket] = k + 1
|
|
91
|
+
prompts.append(prompt)
|
|
92
|
+
if not prompts:
|
|
93
|
+
raise OpError(f"prompt set {p.name}: no prompts" + (f" in buckets {buckets}" if buckets else ""))
|
|
94
|
+
name = (manifest or {}).get("name") or _sets.set_stem(p)
|
|
95
|
+
return PromptSet(name=name, path=p, sha256=sha, prompts=tuple(prompts), manifest=manifest)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _manifest(p: Path) -> dict | None:
|
|
99
|
+
m = _sets.manifest_path(p)
|
|
100
|
+
if not m.is_file():
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
value = json.loads(m.read_text())
|
|
104
|
+
except ValueError:
|
|
105
|
+
raise OpError(f"{m.name}: not JSON") from None
|
|
106
|
+
return value if isinstance(value, dict) else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse(obj, where: str) -> Prompt:
|
|
110
|
+
if not isinstance(obj, dict):
|
|
111
|
+
raise OpError(f"{where}: each line must be a JSON object")
|
|
112
|
+
pid, bucket, messages = obj.get("id"), obj.get("bucket"), obj.get("messages")
|
|
113
|
+
if not isinstance(pid, str) or not pid or "/" in pid or pid.startswith("."):
|
|
114
|
+
raise OpError(f"{where}: 'id' must be a non-empty string without '/'")
|
|
115
|
+
if not isinstance(bucket, str) or not bucket or "/" in bucket or bucket.startswith("."):
|
|
116
|
+
raise OpError(f"{where}: 'bucket' must be a non-empty string without '/'")
|
|
117
|
+
if not isinstance(messages, list) or not messages or not all(
|
|
118
|
+
isinstance(m, dict) and isinstance(m.get("role"), str) for m in messages):
|
|
119
|
+
raise OpError(f"{where}: 'messages' must be a non-empty list of {{role, content}} objects")
|
|
120
|
+
max_tokens = obj.get("max_tokens")
|
|
121
|
+
if max_tokens is not None and (not isinstance(max_tokens, int) or isinstance(max_tokens, bool) or max_tokens < 1):
|
|
122
|
+
raise OpError(f"{where}: 'max_tokens' must be a positive integer")
|
|
123
|
+
extra = {k: v for k, v in obj.items() if k not in ("id", "bucket", "messages", "max_tokens", "kind")}
|
|
124
|
+
return Prompt(id=pid, bucket=bucket, messages=tuple(messages), max_tokens=max_tokens,
|
|
125
|
+
kind=str(obj.get("kind") or "chat"), extra=extra)
|