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,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)