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,396 @@
1
+ """Read safetensors headers (never the weights) from a local directory or the Hugging Face Hub.
2
+
3
+ A ``.safetensors`` file starts with an 8-byte little-endian unsigned integer N, then N bytes
4
+ of JSON::
5
+
6
+ {"<tensor name>": {"dtype": "BF16", "shape": [rows, cols], "data_offsets": [begin, end]},
7
+ ..., "__metadata__": {...}}
8
+
9
+ and then the tensor data. The first 8 + N bytes give every tensor's dtype and shape, which is
10
+ all a bytes-per-token count needs. For a Hub id the local Hugging Face cache is tried first (an
11
+ engine box usually has the snapshot it serves on disk, often with no network); otherwise each
12
+ shard costs one or two HTTP range requests (a few MB at most, whatever the checkpoint size). The
13
+ weights are never downloaded. ``HF_HUB_OFFLINE`` is honoured: offline, only the cache is read.
14
+
15
+ Every header is recorded as (file name, header length, sha256 of the N header bytes), so a
16
+ count can be reproduced against exactly the same tensors.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import math
24
+ import os
25
+ import struct
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+
32
+ from inferencekit.registry import OpError
33
+
34
+ # Bits per element of each safetensors dtype. Packed formats (NVFP4/MXFP4 stored as U8 pairs,
35
+ # FP8 scales) are counted as stored: the bytes on disk are the bytes the kernel reads.
36
+ DTYPE_BITS = {
37
+ "F64": 64, "F32": 32, "F16": 16, "BF16": 16,
38
+ "I64": 64, "I32": 32, "I16": 16, "I8": 8,
39
+ "U64": 64, "U32": 32, "U16": 16, "U8": 8, "BOOL": 8,
40
+ "F8_E4M3": 8, "F8_E5M2": 8, "F8_E8M0": 8, "F8_E4M3FNUZ": 8, "F8_E5M2FNUZ": 8,
41
+ "F6_E2M3": 6, "F6_E3M2": 6, "F4": 4, "C64": 64,
42
+ }
43
+ MAX_HEADER = 100_000_000 # the safetensors format caps the header at 100 MB
44
+ FIRST_READ = 1 << 16 # first range request: the length prefix plus the start of the header
45
+ INDEX = "model.safetensors.index.json"
46
+ SINGLE = "model.safetensors"
47
+
48
+
49
+ class NotFound(OpError):
50
+ """The Hub has no such file at that revision."""
51
+
52
+
53
+ class HubUnavailable(OpError):
54
+ """The Hub could not be reached (offline mode, network error, server error); not a missing file."""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Tensor:
59
+ name: str
60
+ dtype: str
61
+ shape: tuple[int, ...]
62
+ nbytes: int
63
+ file: str
64
+
65
+
66
+ @dataclass
67
+ class Checkpoint:
68
+ """Every tensor of a checkpoint, from its headers, plus how to reproduce the read."""
69
+
70
+ tensors: dict[str, Tensor]
71
+ files: list[dict] # [{file, header_len, header_sha256, tensors, data_bytes}]
72
+ source: str # "directory" | "hub" | "hub-cache"
73
+ commit: str | None = None # the Hub commit the headers came from, when the Hub (or the cache) says
74
+ metadata: dict = field(default_factory=dict)
75
+ local_dir: Path | None = None # the snapshot directory, for headers read from the local HF cache
76
+
77
+ @property
78
+ def headers_digest(self) -> str:
79
+ """One sha256 over the sorted (file, header sha256) list."""
80
+ lines = "".join(f"{f['file']}:{f['header_sha256']}\n" for f in sorted(self.files, key=lambda f: f["file"]))
81
+ return hashlib.sha256(lines.encode()).hexdigest()
82
+
83
+ @property
84
+ def stored_bytes(self) -> int:
85
+ return sum(t.nbytes for t in self.tensors.values())
86
+
87
+
88
+ def tensor_bytes(dtype: str, shape) -> int:
89
+ """Stored bytes of one tensor: elements x bits per element, rounded up to whole bytes."""
90
+ if dtype not in DTYPE_BITS:
91
+ raise OpError(f"unknown safetensors dtype {dtype}; cannot size it")
92
+ return math.ceil(math.prod(int(d) for d in shape) * DTYPE_BITS[dtype] / 8)
93
+
94
+
95
+ def parse_header(raw: bytes, file: str) -> tuple[dict[str, Tensor], dict]:
96
+ """Tensors and the digest entry from the N header bytes of one file."""
97
+ try:
98
+ header = json.loads(raw.decode("utf-8"))
99
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
100
+ raise OpError(f"{file}: safetensors header is not valid JSON ({e})") from None
101
+ if not isinstance(header, dict):
102
+ raise OpError(f"{file}: safetensors header is not a JSON object")
103
+ tensors: dict[str, Tensor] = {}
104
+ data_bytes = 0
105
+ for name, info in header.items():
106
+ if name == "__metadata__":
107
+ continue
108
+ shape = tuple(int(d) for d in info["shape"])
109
+ size = tensor_bytes(info["dtype"], shape)
110
+ begin, end = info["data_offsets"]
111
+ if end - begin != size:
112
+ raise OpError(f"{file}: tensor {name} is {info['dtype']} {list(shape)} = {size} bytes, but its "
113
+ f"data_offsets span {end - begin} bytes")
114
+ tensors[name] = Tensor(name, info["dtype"], shape, size, file)
115
+ data_bytes = max(data_bytes, end)
116
+ entry = {"file": file, "header_len": len(raw), "header_sha256": hashlib.sha256(raw).hexdigest(),
117
+ "tensors": len(tensors), "data_bytes": data_bytes}
118
+ return tensors, entry
119
+
120
+
121
+ def _length(prefix: bytes, file: str) -> int:
122
+ if len(prefix) < 8:
123
+ raise OpError(f"{file}: shorter than the 8-byte safetensors length prefix")
124
+ (n,) = struct.unpack("<Q", prefix[:8])
125
+ if n > MAX_HEADER:
126
+ raise OpError(f"{file}: header length {n} exceeds the safetensors limit; not a safetensors file?")
127
+ return n
128
+
129
+
130
+ def _merge(parts: list[tuple[dict[str, Tensor], dict]], source: str, commit: str | None = None) -> Checkpoint:
131
+ tensors: dict[str, Tensor] = {}
132
+ for t, _ in parts:
133
+ dup = set(t) & set(tensors)
134
+ if dup:
135
+ raise OpError(f"tensor {sorted(dup)[0]} appears in two shards; pass the directory with its index file")
136
+ tensors.update(t)
137
+ return Checkpoint(tensors, [e for _, e in parts], source, commit)
138
+
139
+
140
+ # --------------------------------------------------------------------------- local files
141
+
142
+ def local_header(path: Path) -> bytes:
143
+ with open(path, "rb") as f:
144
+ n = _length(f.read(8), path.name)
145
+ raw = f.read(n)
146
+ if len(raw) != n:
147
+ raise OpError(f"{path.name}: truncated header ({len(raw)} of {n} bytes)")
148
+ return raw
149
+
150
+
151
+ def local_files(directory: Path) -> list[Path]:
152
+ """Shards named by the index file when there is one (so stray copies are not counted twice)."""
153
+ index = directory / INDEX
154
+ if index.is_file():
155
+ names = sorted(set(json.loads(index.read_text())["weight_map"].values()))
156
+ return [directory / n for n in names]
157
+ return sorted(directory.glob("*.safetensors"))
158
+
159
+
160
+ def load_local(directory: Path) -> Checkpoint | None:
161
+ files = local_files(directory)
162
+ if not files:
163
+ return None
164
+ try:
165
+ return _merge([parse_header(local_header(p), p.name) for p in files], "directory")
166
+ except OSError as e:
167
+ raise OpError(f"could not read a safetensors header in {directory}: {e}") from None
168
+
169
+
170
+ # --------------------------------------------------------------------------- Hugging Face Hub
171
+
172
+ _TRUE = {"1", "ON", "YES", "TRUE"}
173
+
174
+
175
+ def _hf_constants():
176
+ """huggingface_hub.constants when that package is installed, else None."""
177
+ try:
178
+ from huggingface_hub import constants
179
+ except Exception: # noqa: BLE001 - not installed, or broken
180
+ return None
181
+ return constants
182
+
183
+
184
+ def hub_offline() -> bool:
185
+ """HF_HUB_OFFLINE (or TRANSFORMERS_OFFLINE), as huggingface_hub reads it."""
186
+ constants = _hf_constants()
187
+ if constants is not None and getattr(constants, "HF_HUB_OFFLINE", False):
188
+ return True
189
+ return any(os.environ.get(k, "").upper() in _TRUE for k in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE"))
190
+
191
+
192
+ def hub_cache_dir() -> Path:
193
+ """The Hugging Face hub cache: huggingface_hub's own setting when installed, else the same env vars."""
194
+ constants = _hf_constants()
195
+ if constants is not None and getattr(constants, "HF_HUB_CACHE", None):
196
+ return Path(constants.HF_HUB_CACHE)
197
+ env = os.environ
198
+ if env.get("HF_HUB_CACHE") or env.get("HUGGINGFACE_HUB_CACHE"):
199
+ return Path(env.get("HF_HUB_CACHE") or env["HUGGINGFACE_HUB_CACHE"]).expanduser()
200
+ home = env.get("HF_HOME") or os.path.join(env.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"),
201
+ "huggingface")
202
+ return Path(home).expanduser() / "hub"
203
+
204
+
205
+ def cached_file(repo: str, filename: str, revision: str | None) -> Path | None:
206
+ """A file of ``repo`` at ``revision`` in the local HF cache, or None. Never touches the network.
207
+ Uses huggingface_hub.try_to_load_from_cache when installed, else the same cache layout
208
+ (models--org--name/refs/<rev> -> snapshots/<commit>/<file>)."""
209
+ try:
210
+ from huggingface_hub import try_to_load_from_cache
211
+ except Exception: # noqa: BLE001 - not installed, or too old
212
+ try_to_load_from_cache = None
213
+ if try_to_load_from_cache is not None:
214
+ try:
215
+ p = try_to_load_from_cache(repo, filename, revision=revision)
216
+ except Exception: # noqa: BLE001 - an unusable cache is the same as no cache
217
+ return None
218
+ return Path(p) if isinstance(p, str) and os.path.isfile(p) else None
219
+ folder = hub_cache_dir() / ("models--" + repo.replace("/", "--"))
220
+ rev = revision or "main"
221
+ ref = folder / "refs" / rev
222
+ try:
223
+ commit = ref.read_text().strip() if ref.is_file() else rev
224
+ except OSError:
225
+ return None
226
+ p = folder / "snapshots" / commit / filename
227
+ return p if p.is_file() else None
228
+
229
+
230
+ def snapshot_commit(path: Path) -> str | None:
231
+ """The commit of a cached file: the directory name after 'snapshots' in its (unresolved) path."""
232
+ parts = Path(path).parts
233
+ if "snapshots" in parts:
234
+ i = len(parts) - 1 - parts[::-1].index("snapshots")
235
+ if i + 1 < len(parts):
236
+ return parts[i + 1]
237
+ return None
238
+
239
+
240
+ def load_hub_cache(repo: str, revision: str | None) -> Checkpoint | None:
241
+ """Headers of a Hub checkpoint whose shards are all in the local HF cache, else None."""
242
+ index = cached_file(repo, INDEX, revision)
243
+ if index is not None:
244
+ try:
245
+ names = sorted(set(json.loads(index.read_text())["weight_map"].values()))
246
+ except (OSError, ValueError, KeyError):
247
+ return None
248
+ else:
249
+ names = [SINGLE]
250
+ paths = [cached_file(repo, n, revision) for n in names]
251
+ if not names or any(p is None for p in paths):
252
+ return None
253
+ commits = {snapshot_commit(p) for p in paths} | ({snapshot_commit(index)} if index else set())
254
+ if len(commits) != 1: # files from different snapshots: not one checkpoint
255
+ return None
256
+ try:
257
+ parts = [parse_header(local_header(p), n) for p, n in zip(paths, names)]
258
+ except OSError:
259
+ return None
260
+ ck = _merge(parts, "hub-cache", commits.pop())
261
+ ck.local_dir = paths[0].parent
262
+ return ck
263
+
264
+
265
+ def hub_endpoint() -> str:
266
+ return (os.environ.get("HF_ENDPOINT") or "https://huggingface.co").rstrip("/")
267
+
268
+
269
+ def hub_token() -> str | None:
270
+ """HF_TOKEN, else the huggingface_hub login when that package is installed. Never recorded."""
271
+ token = os.environ.get("HF_TOKEN")
272
+ if token:
273
+ return token
274
+ try:
275
+ from huggingface_hub import get_token
276
+
277
+ return get_token()
278
+ except Exception: # noqa: BLE001 - not installed, or an old version without get_token
279
+ return None
280
+
281
+
282
+ def hub_url(repo: str, filename: str, revision: str | None) -> str:
283
+ rev = urllib.parse.quote(revision or "main", safe="")
284
+ return f"{hub_endpoint()}/{repo}/resolve/{rev}/{urllib.parse.quote(filename)}"
285
+
286
+
287
+ class _CommitRedirects(urllib.request.HTTPRedirectHandler):
288
+ """Follows redirects and keeps the X-Repo-Commit header the Hub puts on the redirect itself."""
289
+
290
+ commit: str | None = None
291
+
292
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
293
+ self.commit = self.commit or headers.get("X-Repo-Commit")
294
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
295
+
296
+
297
+ def _get(url: str, byte_range: tuple[int, int] | None, limit: int) -> tuple[bytes, str | None]:
298
+ """GET at most ``limit`` bytes. The token is an unredirected header, so it is not forwarded
299
+ to the storage host a Hub download redirects to (that URL is pre-signed)."""
300
+ req = urllib.request.Request(url)
301
+ if byte_range:
302
+ req.add_header("Range", f"bytes={byte_range[0]}-{byte_range[1]}")
303
+ token = hub_token()
304
+ if token:
305
+ req.add_unredirected_header("Authorization", f"Bearer {token}")
306
+ redirects = _CommitRedirects()
307
+ with urllib.request.build_opener(redirects).open(req, timeout=60) as r:
308
+ commit = redirects.commit or r.headers.get("X-Repo-Commit")
309
+ if byte_range and r.status == 200 and byte_range[0] > 0: # range ignored: skip to the offset
310
+ r.read(byte_range[0])
311
+ # Read only what is needed even if the server ignored the range: never the weights.
312
+ return r.read(limit), commit
313
+
314
+
315
+ def hub_small_file(repo: str, filename: str, revision: str | None) -> tuple[bytes | None, str | None]:
316
+ """A small file (index, config) from the Hub, or (None, None) when the repo has no such file."""
317
+ if hub_offline():
318
+ raise HubUnavailable(f"could not fetch {filename} for '{repo}': HF_HUB_OFFLINE is set")
319
+ try:
320
+ return _get(hub_url(repo, filename, revision), None, 64 << 20)
321
+ except urllib.error.HTTPError as e:
322
+ if e.code == 404:
323
+ return None, None
324
+ cls = HubUnavailable if e.code >= 500 else OpError
325
+ raise cls(f"could not fetch {filename} for '{repo}': HTTP {e.code}") from None
326
+ except OSError as e: # connection refused, DNS, timeout: the Hub is unreachable
327
+ raise HubUnavailable(f"could not fetch {filename} for '{repo}': {e}") from None
328
+
329
+
330
+ def hub_header(repo: str, filename: str, revision: str | None) -> tuple[bytes, str | None]:
331
+ if hub_offline():
332
+ raise HubUnavailable(f"could not read the header of {filename} in '{repo}': HF_HUB_OFFLINE is set")
333
+ url = hub_url(repo, filename, revision)
334
+ try:
335
+ first, commit = _get(url, (0, FIRST_READ - 1), FIRST_READ)
336
+ n = _length(first, filename)
337
+ raw = first[8:8 + n]
338
+ if len(raw) < n:
339
+ rest, _ = _get(url, (8 + len(raw), 8 + n - 1), n - len(raw))
340
+ raw += rest
341
+ except urllib.error.HTTPError as e:
342
+ cls = NotFound if e.code == 404 else HubUnavailable if e.code >= 500 else OpError
343
+ raise cls(f"could not read the header of {filename} in '{repo}': HTTP {e.code}") from None
344
+ except OSError as e: # connection refused, DNS, timeout: the Hub is unreachable
345
+ raise HubUnavailable(f"could not read the header of {filename} in '{repo}': {e}") from None
346
+ if len(raw) != n:
347
+ raise OpError(f"{filename}: got {len(raw)} of {n} header bytes from the Hub")
348
+ return raw, commit
349
+
350
+
351
+ def load_hub(repo: str, revision: str | None) -> Checkpoint | None:
352
+ """Headers of every shard named by the index (or the single model.safetensors): from the local
353
+ HF cache when every shard is there, else by range requests. Offline with no cached copy, or with
354
+ the Hub unreachable, raises HubUnavailable (the caller can fall back to the config method)."""
355
+ cached = load_hub_cache(repo, revision)
356
+ if cached is not None:
357
+ return cached
358
+ if hub_offline():
359
+ raise HubUnavailable(f"HF_HUB_OFFLINE is set and the safetensors shards of '{repo}' at "
360
+ f"{revision or 'main'} are not all in the local Hugging Face cache ({hub_cache_dir()})")
361
+ index, commit = hub_small_file(repo, INDEX, revision)
362
+ if index is not None:
363
+ try:
364
+ names = sorted(set(json.loads(index)["weight_map"].values()))
365
+ except (ValueError, KeyError) as e:
366
+ raise OpError(f"{repo}: unreadable {INDEX} ({e})") from None
367
+ else:
368
+ names = [SINGLE]
369
+ parts = []
370
+ for name in names:
371
+ try:
372
+ raw, c = hub_header(repo, name, revision)
373
+ except NotFound:
374
+ if index is None: # no index and no model.safetensors: not a safetensors repo
375
+ return None
376
+ raise
377
+ commit = commit or c
378
+ parts.append(parse_header(raw, name))
379
+ return _merge(parts, "hub", commit)
380
+
381
+
382
+ def is_hub_id(model: str) -> bool:
383
+ return not Path(model).exists() and model.count("/") == 1 and not model.startswith((".", "/"))
384
+
385
+
386
+ def load(model: str, revision: str | None = None) -> Checkpoint | None:
387
+ """Headers for a model directory, a file inside one (e.g. its config.json), or a Hub id.
388
+ None when there are no safetensors files to read."""
389
+ p = Path(model)
390
+ if p.is_dir():
391
+ return load_local(p)
392
+ if p.is_file():
393
+ return load_local(p.parent)
394
+ if is_hub_id(model):
395
+ return load_hub(model, revision)
396
+ return None
inferencekit/cli.py ADDED
@@ -0,0 +1,274 @@
1
+ """The ``inferencekit`` command: argparse built from the operation registry.
2
+
3
+ Each operation's ``cli`` path becomes a (sub)command: ("nsys", "analyze") ->
4
+ ``inferencekit nsys analyze``. Arguments map one to one: ``prompt_len`` -> ``--prompt-len``,
5
+ positional arguments stay positional, arrays repeat (``--keep-env A --keep-env B``).
6
+ Commands that run another program take it after ``--``:
7
+
8
+ inferencekit nsys capture -- python serve_bench.py --steps 64
9
+
10
+ Output on stdout. An evidence record is printed tersely by default: its summary line (for
11
+ ``bench rounds``, then each engine's ratio to the best one with its 95% CI), one line per gate or
12
+ claim that did not PASS (verdict, name, reason cut to 160 characters), a count of limitations and
13
+ missing items, and ``evidence: <path>`` (the full record, already on disk). The
14
+ full record as JSON comes with ``--json`` (before or after the command) or
15
+ ``INFERENCEKIT_OUTPUT=json``; programs that parse stdout set one of them (``bench rounds`` does
16
+ for its ``bench serve`` children). Results that are not records (listings, validation results)
17
+ print as JSON either way, and Markdown results (``report --format md``, ``intake template``,
18
+ ``ledger trailers``) as their text. Errors go to stderr with exit code 1.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from inferencekit import __version__, registry
30
+ from inferencekit.evidence import default_root, set_default_root
31
+ from inferencekit.registry import OpError
32
+
33
+ _PY_TYPES = {"string": str, "integer": int, "number": float}
34
+ OUTPUT_ENV = "INFERENCEKIT_OUTPUT"
35
+ OUTPUT_MODES = ("terse", "json")
36
+ JSON_HELP = "Print the full JSON result instead of the terse summary (same as INFERENCEKIT_OUTPUT=json)."
37
+ CLAIM_VERDICTS = ("FAIL", "UNPROVEN", "NOT RUN") # the claim verdicts that are not PASS
38
+ REASON_CHARS = 160
39
+
40
+
41
+ def _flag(name: str) -> str:
42
+ return "--" + name.replace("_", "-")
43
+
44
+
45
+ def _help(text: str) -> str:
46
+ # argparse %-formats help strings ("%(default)s"), so a literal "%" must be doubled.
47
+ return text.replace("%", "%%")
48
+
49
+
50
+ def _add_arguments(parser: argparse.ArgumentParser, op: registry.Operation) -> None:
51
+ for a in op.args:
52
+ if a.name == "argv" and op.passthrough:
53
+ continue # filled from the words after "--"
54
+ help_text = _help(a.help + (f" (default: {a.default})" if a.default not in (None, [], "") else ""))
55
+ if a.positional:
56
+ nargs = "+" if a.type == "array" else None
57
+ parser.add_argument(a.name, nargs=nargs, type=_PY_TYPES.get(a.items if a.type == "array" else a.type),
58
+ choices=a.choices, help=help_text)
59
+ elif a.type == "boolean":
60
+ parser.add_argument(_flag(a.name), dest=a.name, action=argparse.BooleanOptionalAction, default=None,
61
+ help=help_text)
62
+ elif a.type == "array":
63
+ parser.add_argument(_flag(a.name), dest=a.name, action="append", type=_PY_TYPES.get(a.items, str),
64
+ choices=a.choices, default=None, help=help_text + " (repeatable)")
65
+ else:
66
+ parser.add_argument(_flag(a.name), dest=a.name, type=_PY_TYPES[a.type], choices=a.choices, default=None,
67
+ help=help_text)
68
+ if not any(a.name == "json" for a in op.args):
69
+ # Also accepted after the command (`inferencekit gates run ... --json`). SUPPRESS: a subcommand that
70
+ # was not given --json must not reset a --json given before it.
71
+ parser.add_argument("--json", dest="json_output", action="store_true", default=argparse.SUPPRESS,
72
+ help=JSON_HELP)
73
+ parser.set_defaults(_op=op.name)
74
+
75
+
76
+ def build_parser() -> argparse.ArgumentParser:
77
+ ops = registry.load_all()
78
+ parser = argparse.ArgumentParser(prog="inferencekit", description="Measure-first LLM inference optimisation. "
79
+ "Every command writes a content-addressed evidence record under runs/.")
80
+ parser.add_argument("--version", action="version", version=f"inferencekit {__version__}")
81
+ parser.add_argument("--runs-dir", help="Evidence store directory (default: $INFERENCEKIT_RUNS_DIR, else the "
82
+ "deprecated $INFERKIT_RUNS_DIR, else ./runs). Records made with --run-dir are also copied to "
83
+ "<run-dir>/evidence/.")
84
+ parser.add_argument("--json", dest="json_output", action="store_true", default=False, help=JSON_HELP)
85
+ top = parser.add_subparsers(dest="command", metavar="<command>")
86
+ groups: dict[str, argparse._SubParsersAction] = {}
87
+ for op in ops.values():
88
+ if len(op.cli) == 1:
89
+ sub = top.add_parser(op.cli[0], help=_help(op.summary), description=op.summary)
90
+ else:
91
+ head, leaf = op.cli
92
+ if head not in groups:
93
+ grp = top.add_parser(head, help=f"{head} commands")
94
+ groups[head] = grp.add_subparsers(dest="subcommand", metavar="<subcommand>")
95
+ sub = groups[head].add_parser(leaf, help=_help(op.summary), description=op.summary)
96
+ _add_arguments(sub, op)
97
+ mcp = top.add_parser("mcp", help="Model Context Protocol server")
98
+ mcp_sub = mcp.add_subparsers(dest="subcommand", metavar="<subcommand>")
99
+ mcp_sub.add_parser("serve", help="Serve every operation as an MCP tool over stdio").set_defaults(_op="mcp:serve")
100
+ return parser
101
+
102
+
103
+ # --------------------------------------------------------------------------- output
104
+
105
+ def output_mode(flag: bool) -> str:
106
+ """``json`` with --json or INFERENCEKIT_OUTPUT=json, else ``terse`` (an unknown value is reported)."""
107
+ if flag:
108
+ return "json"
109
+ value = (os.environ.get(OUTPUT_ENV) or "terse").strip().lower()
110
+ if value not in OUTPUT_MODES:
111
+ print(f"inferencekit: {OUTPUT_ENV}={value!r} is not one of {list(OUTPUT_MODES)}; using terse",
112
+ file=sys.stderr)
113
+ return "terse"
114
+ return value
115
+
116
+
117
+ def is_record(result) -> bool:
118
+ """An evidence record (it has an id, a kind and a summary), as opposed to a listing or a check result."""
119
+ return isinstance(result, dict) and all(isinstance(result.get(k), str) and result.get(k)
120
+ for k in ("evidence_id", "kind", "summary"))
121
+
122
+
123
+ def _one_line(text, limit: int | None = None) -> str:
124
+ out = " ".join(str(text).split())
125
+ return out if limit is None or len(out) <= limit else out[: limit - 3] + "..."
126
+
127
+
128
+ def problem_lines(record: dict) -> list[str]:
129
+ """One line per gate or claim of a record that did not PASS: ``<VERDICT> <name>: <reason>``.
130
+
131
+ Read from ``data.gates`` (gates run, gates spec-exact), else ``data.verdicts`` (one verdict per
132
+ engine: quality score), else the record's own ``data.verdict`` / ``data.reason`` (correctness), plus
133
+ every ``HARD GATE`` limitation (bench decode, bench rounds). A gate that did not run and was not
134
+ expected (``applicable`` false) is left out: it is not a finding.
135
+ """
136
+ data = record.get("data") if isinstance(record.get("data"), dict) else {}
137
+ lines = []
138
+ gates = data.get("gates")
139
+ items = gates.items() if isinstance(gates, dict) else enumerate(gates) if isinstance(gates, list) else ()
140
+ for key, g in items:
141
+ if not isinstance(g, dict) or g.get("verdict") not in CLAIM_VERDICTS:
142
+ continue
143
+ if g["verdict"] == "NOT RUN" and not g.get("applicable"):
144
+ continue
145
+ reason = f": {_one_line(g['reason'], REASON_CHARS)}" if g.get("reason") else ""
146
+ lines.append(f"{g['verdict']} {g.get('gate') or key}{reason}")
147
+ verdicts = data.get("verdicts")
148
+ if not lines and isinstance(verdicts, dict):
149
+ lines += [f"{v} {name}" for name, v in verdicts.items() if v in CLAIM_VERDICTS]
150
+ if not lines and data.get("verdict") in CLAIM_VERDICTS[:2]:
151
+ name = (data.get("request") or {}).get("op") or record["kind"]
152
+ reason = f": {_one_line(data['reason'], REASON_CHARS)}" if isinstance(data.get("reason"), str) else ""
153
+ lines.append(f"{data['verdict']} {name}{reason}")
154
+ for lim in record.get("limitations") or []:
155
+ if isinstance(lim, str) and lim.startswith("HARD GATE"):
156
+ lines.append("FAIL hard gate: " + _one_line(lim.split(":", 1)[1], REASON_CHARS))
157
+ return lines
158
+
159
+
160
+ def _num(x) -> bool:
161
+ return isinstance(x, (int, float)) and not isinstance(x, bool)
162
+
163
+
164
+ def ratio_line(record: dict) -> str | None:
165
+ """bench rounds: every other engine's ratio to the best engine, with its 95% CI, for the metric and bucket
166
+ the summary names (the first row whose engine is the best), e.g.
167
+ ``ratio to best decode_tok_s [all] (a): b 0.52 (95% CI 0.5-0.54)``. None for other records."""
168
+ data = record.get("data") if isinstance(record.get("data"), dict) else {}
169
+ if not isinstance(data.get("rows"), list) or not isinstance(data.get("plan"), dict):
170
+ return None
171
+ rows = [r for r in data["rows"] if isinstance(r, dict)]
172
+ head = next((r for r in rows if r.get("engine") == r.get("best")), None)
173
+ if head is None:
174
+ return None
175
+ parts = []
176
+ for r in rows:
177
+ if (r.get("bucket"), r.get("metric")) != (head.get("bucket"), head.get("metric")) or \
178
+ r.get("engine") == head.get("engine") or not _num(r.get("ratio_to_best")):
179
+ continue
180
+ ci = r.get("ratio_ci95") if isinstance(r.get("ratio_ci95"), list) else []
181
+ lo, hi = (ci + [None, None])[:2]
182
+ parts.append(f"{r['engine']} {r['ratio_to_best']:.3g}" + (f" (95% CI {lo:.3g}-{hi:.3g})"
183
+ if _num(lo) and _num(hi) else ""))
184
+ if not parts:
185
+ return None
186
+ more = f", +{len(parts) - 6} more" if len(parts) > 6 else ""
187
+ return (f"ratio to best {head.get('metric')} [{head.get('bucket')}] ({head.get('engine')}): "
188
+ + ", ".join(parts[:6]) + more)
189
+
190
+
191
+ def evidence_paths(record: dict) -> tuple[Path | None, Path | None]:
192
+ """Where the full record is: the evidence store copy, and the run-directory copy when there is one."""
193
+ eid = record["evidence_id"]
194
+ stored = (default_root() / eid / "evidence.json").resolve()
195
+ data = record.get("data") if isinstance(record.get("data"), dict) else {}
196
+ args = (data.get("request") or {}).get("args") or {}
197
+ run_dir = args.get("run_dir") or data.get("run_dir") # bench rounds defaults it to the manifest's directory
198
+ copy = None
199
+ if isinstance(run_dir, str) and run_dir:
200
+ copy = (Path(run_dir).expanduser() / "evidence" / f"{eid}.json").resolve()
201
+ return (stored if stored.is_file() else None), (copy if copy is not None and copy.is_file() else None)
202
+
203
+
204
+ def terse(record: dict) -> str:
205
+ """The terse view of an evidence record (see the module docstring)."""
206
+ lines = [_one_line(record["summary"])]
207
+ ratios = ratio_line(record)
208
+ if ratios:
209
+ lines.append(ratios)
210
+ lines += problem_lines(record)
211
+ n_lim = len(record.get("limitations") or [])
212
+ n_missing = len((record.get("coverage") or {}).get("missing") or [])
213
+ if n_lim or n_missing:
214
+ lines.append(f"{n_lim} limitation(s), {n_missing} missing item(s): listed in the evidence file")
215
+ stored, copy = evidence_paths(record)
216
+ if stored is None and copy is None:
217
+ lines.append(f"evidence: {record['evidence_id']} (no evidence file found under {default_root()})")
218
+ else:
219
+ lines.append(f"evidence: {stored or copy}")
220
+ if stored is not None and copy is not None:
221
+ lines.append(f"run-dir copy: {copy}")
222
+ return "\n".join(lines)
223
+
224
+
225
+ def print_result(result, mode: str) -> None:
226
+ if isinstance(result, dict) and result.get("format") == "md" and "markdown" in result:
227
+ print(result["markdown"])
228
+ print(f"(written to {result['path']})", file=sys.stderr)
229
+ elif mode == "terse" and is_record(result):
230
+ print(terse(result))
231
+ else:
232
+ print(json.dumps(result, indent=2, ensure_ascii=False))
233
+
234
+
235
+ def main(argv: list[str] | None = None) -> int:
236
+ argv = list(sys.argv[1:] if argv is None else argv)
237
+ # Split at the first "--": everything after it is the command to profile, untouched by argparse.
238
+ passthrough = None
239
+ if "--" in argv:
240
+ i = argv.index("--")
241
+ argv, passthrough = argv[:i], argv[i + 1:]
242
+ parser = build_parser()
243
+ ns = parser.parse_args(argv)
244
+ if ns.runs_dir:
245
+ set_default_root(ns.runs_dir)
246
+ op_name = getattr(ns, "_op", None)
247
+ if op_name is None:
248
+ parser.print_help()
249
+ return 2
250
+ if op_name == "mcp:serve":
251
+ from inferencekit.mcp_server import serve
252
+
253
+ serve()
254
+ return 0
255
+ op = registry.get(op_name)
256
+ args = {a.name: getattr(ns, a.name) for a in op.args if hasattr(ns, a.name)}
257
+ if op.passthrough:
258
+ if not passthrough:
259
+ parser.error(f"inferencekit {' '.join(op.cli)} needs the command to run after '--'")
260
+ args["argv"] = passthrough
261
+ elif passthrough:
262
+ parser.error("'--' is only valid for commands that run another program")
263
+ mode = output_mode(getattr(ns, "json_output", False))
264
+ try:
265
+ result = registry.call(op_name, args)
266
+ except OpError as e:
267
+ print(f"inferencekit: error: {e}", file=sys.stderr)
268
+ return 1
269
+ print_result(result, mode)
270
+ return 0
271
+
272
+
273
+ if __name__ == "__main__":
274
+ raise SystemExit(main())