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