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,897 @@
1
+ """Engine servers for round runs: launch, readiness, warm-up, /metrics, stop, GPU memory, GPU lock.
2
+
3
+ One (round, engine) cell of ``bench rounds`` does, in order:
4
+
5
+ 1. check the port is free (a leftover server on it would answer the readiness probe
6
+ and get benchmarked in place of the engine under test);
7
+ 2. launch the server under a clean environment (only the variables the manifest
8
+ lists, plus PATH/HOME/CUDA basics), in its own process group, stdout+stderr to a
9
+ log file;
10
+ 3. poll ``GET <ready_check>`` until it answers 200, the process exits, or the
11
+ startup timeout passes;
12
+ 4. warm up with a few short requests (untimed), each salted with a unique prefix tag so
13
+ the warm-up never seeds the prefix cache for a prompt that is timed afterwards;
14
+ 5. scrape ``/metrics`` (Prometheus text) before and after the measured run, when the
15
+ engine exposes it;
16
+ 6. stop the whole process group: SIGTERM, then SIGKILL after a grace period. Engines
17
+ fork workers (vLLM's engine core, SGLang's scheduler and detokenizer); signalling
18
+ only the launcher would leave them holding GPU memory;
19
+ 7. check that GPU memory returned to the level measured before the launch
20
+ (nvidia-smi; skipped when absent), so the next engine starts on an empty GPU.
21
+
22
+ The whole run holds an exclusive lock file (``gpu_lock``), so two round runs (or a
23
+ round run and an agent's ad-hoc benchmark that also takes the lock) cannot share the GPU.
24
+
25
+ Environment values are passed to the server but never recorded: records list names only.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import ast
31
+ import contextlib
32
+ import fcntl
33
+ import json
34
+ import os
35
+ import re
36
+ import signal
37
+ import socket
38
+ import subprocess
39
+ import time
40
+ import urllib.error
41
+ import urllib.request
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+
45
+ from inferencekit.evidence import looks_secret_name
46
+ from inferencekit.proc import run, which
47
+ from inferencekit.registry import OpError
48
+
49
+ # Passed through from the caller in addition to the manifest's own variables: enough to
50
+ # find binaries, the home directory (HF cache) and the GPU, nothing that tunes an engine.
51
+ SERVER_BASE_ENV = ("PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "CUDA_VISIBLE_DEVICES",
52
+ "CUDA_HOME", "CUDA_PATH", "LD_LIBRARY_PATH", "NVIDIA_VISIBLE_DEVICES",
53
+ "NVIDIA_DRIVER_CAPABILITIES")
54
+
55
+
56
+ # --------------------------------------------------------------------------- environment
57
+
58
+ def server_env(listed: dict | None) -> tuple[dict, dict]:
59
+ """The environment for one engine server, and a names-only description of it.
60
+
61
+ ``listed`` is the manifest's ``env`` object. A string (or number) value is set as
62
+ given; ``null`` means "inherit this variable from the caller if it is set", which is
63
+ how a secret such as HF_TOKEN reaches the server without being written into the
64
+ manifest. Returns ``(env, info)`` where ``info`` holds only names.
65
+ """
66
+ env = {k: os.environ[k] for k in SERVER_BASE_ENV if k in os.environ}
67
+ env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
68
+ set_names, inherited, missing, secret_values = [], [], [], []
69
+ for name, value in (listed or {}).items():
70
+ name = str(name)
71
+ if value is None:
72
+ if name in os.environ:
73
+ env[name] = os.environ[name]
74
+ inherited.append(name)
75
+ else:
76
+ missing.append(name)
77
+ continue
78
+ env[name] = value if isinstance(value, str) else json.dumps(value) if isinstance(value, (dict, list)) \
79
+ else str(value).lower() if isinstance(value, bool) else str(value)
80
+ set_names.append(name)
81
+ if looks_secret_name(name):
82
+ secret_values.append(name)
83
+ info = {"base": sorted(k for k in SERVER_BASE_ENV if k in env), "set": sorted(set_names),
84
+ "inherited": sorted(inherited), "missing": sorted(missing),
85
+ "secret_values_in_manifest": sorted(secret_values)}
86
+ return env, info
87
+
88
+
89
+ # --------------------------------------------------------------------------- HTTP helpers
90
+
91
+ def http_get(url: str, timeout: float = 5.0) -> tuple[int | None, bytes | str]:
92
+ """(status, body) or (None, error text). Never raises for network errors."""
93
+ try:
94
+ with urllib.request.urlopen(url, timeout=timeout) as r: # noqa: S310 - local engine URLs only
95
+ return r.status, r.read()
96
+ except urllib.error.HTTPError as e:
97
+ return e.code, e.read() if e.fp else b""
98
+ except (urllib.error.URLError, OSError, ValueError) as e:
99
+ return None, str(getattr(e, "reason", e))
100
+
101
+
102
+ def http_post_json(url: str, body: dict, timeout: float = 60.0) -> tuple[int | None, dict | str]:
103
+ data = json.dumps(body).encode()
104
+ req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
105
+ try:
106
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
107
+ raw = r.read()
108
+ try:
109
+ return r.status, json.loads(raw)
110
+ except ValueError:
111
+ return r.status, raw.decode(errors="replace")[:500]
112
+ except urllib.error.HTTPError as e:
113
+ return e.code, (e.read() if e.fp else b"").decode(errors="replace")[:500]
114
+ except (urllib.error.URLError, OSError, ValueError) as e:
115
+ return None, str(getattr(e, "reason", e))
116
+
117
+
118
+ def port_in_use(host: str, port: int, timeout: float = 0.5) -> bool:
119
+ """True when something already accepts connections on host:port."""
120
+ with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
121
+ s.settimeout(timeout)
122
+ return s.connect_ex((host, port)) == 0
123
+
124
+
125
+ def served_model(base_url: str, timeout: float = 5.0) -> str | None:
126
+ """The first model id in ``GET /v1/models`` (the name requests must use), or None."""
127
+ status, body = http_get(base_url.rstrip("/") + "/v1/models", timeout)
128
+ if status != 200:
129
+ return None
130
+ try:
131
+ data = json.loads(body)
132
+ return str(data["data"][0]["id"])
133
+ except (ValueError, KeyError, IndexError, TypeError):
134
+ return None
135
+
136
+
137
+ # --------------------------------------------------------------------------- processes
138
+
139
+ @dataclass
140
+ class Server:
141
+ """A launched engine server (leader of its own process group)."""
142
+
143
+ argv: list[str]
144
+ log_path: Path
145
+ popen: subprocess.Popen
146
+ started: float = field(default_factory=time.monotonic)
147
+
148
+ @property
149
+ def pgid(self) -> int:
150
+ return self.popen.pid # start_new_session=True: the child leads a new session and group
151
+
152
+
153
+ def launch(argv: list[str], env: dict, log_path: Path, cwd: str | None = None) -> Server:
154
+ """Start ``argv`` in a new process group with stdout and stderr appended to ``log_path``.
155
+
156
+ Raises OSError (e.g. FileNotFoundError) when the program cannot be started.
157
+ """
158
+ log_path.parent.mkdir(parents=True, exist_ok=True)
159
+ with open(log_path, "ab") as log:
160
+ # The child keeps its own copy of the file descriptor; ours can close right away.
161
+ popen = subprocess.Popen(argv, env=env, cwd=cwd, stdin=subprocess.DEVNULL, stdout=log,
162
+ stderr=subprocess.STDOUT, start_new_session=True)
163
+ return Server(list(argv), log_path, popen)
164
+
165
+
166
+ def group_members(pgid: int) -> list[int] | None:
167
+ """Live (non-zombie) pids in process group ``pgid``; None when /proc is unavailable."""
168
+ proc = Path("/proc")
169
+ if not proc.is_dir():
170
+ return None
171
+ out = []
172
+ for d in proc.iterdir():
173
+ if not d.name.isdigit():
174
+ continue
175
+ try:
176
+ fields = (d / "stat").read_text().rsplit(")", 1)[1].split()
177
+ except (OSError, IndexError):
178
+ continue
179
+ # fields: state ppid pgrp ...
180
+ if len(fields) >= 3 and fields[2] == str(pgid) and fields[0] != "Z":
181
+ out.append(int(d.name))
182
+ return sorted(out)
183
+
184
+
185
+ def group_alive(pgid: int) -> bool:
186
+ members = group_members(pgid)
187
+ if members is not None:
188
+ return bool(members)
189
+ try:
190
+ os.killpg(pgid, 0)
191
+ return True
192
+ except ProcessLookupError:
193
+ return False
194
+ except PermissionError:
195
+ return True
196
+
197
+
198
+ def _signal_group(pgid: int, sig: int) -> None:
199
+ try:
200
+ os.killpg(pgid, sig)
201
+ except (ProcessLookupError, PermissionError):
202
+ pass
203
+
204
+
205
+ def _wait_group_gone(server: Server, timeout: float) -> bool:
206
+ deadline = time.monotonic() + timeout
207
+ while True:
208
+ server.popen.poll() # reap the leader so it does not linger as a zombie
209
+ if not group_alive(server.pgid):
210
+ return True
211
+ if time.monotonic() >= deadline:
212
+ return False
213
+ time.sleep(0.05)
214
+
215
+
216
+ def stop(server: Server, grace_s: float = 30.0) -> dict:
217
+ """Stop the server's whole process group: SIGTERM, then SIGKILL after ``grace_s``.
218
+
219
+ The group is signalled even when the leader already exited, because its children
220
+ (engine workers) can outlive it. Returns what happened, including whether every
221
+ process in the group is gone.
222
+ """
223
+ t0 = time.monotonic()
224
+ leader_was_running = server.popen.poll() is None
225
+ _signal_group(server.pgid, signal.SIGTERM)
226
+ method = "sigterm" if leader_was_running else "already_exited"
227
+ gone = _wait_group_gone(server, grace_s)
228
+ if not gone:
229
+ method = "sigkill"
230
+ _signal_group(server.pgid, signal.SIGKILL)
231
+ gone = _wait_group_gone(server, 10.0)
232
+ with contextlib.suppress(subprocess.TimeoutExpired):
233
+ server.popen.wait(timeout=5)
234
+ return {"method": method, "exit_code": server.popen.returncode, "group_gone": gone,
235
+ "seconds": round(time.monotonic() - t0, 3), "uptime_s": round(time.monotonic() - server.started, 3)}
236
+
237
+
238
+ def wait_ready(server: Server, url: str, timeout_s: float, poll_s: float = 0.5) -> dict:
239
+ """Poll ``GET url`` until it returns 200, the server exits, or ``timeout_s`` passes.
240
+
241
+ Returns ``{"status": "ready" | "exited" | "timeout", "seconds", "last": ...}``.
242
+ """
243
+ t0 = time.monotonic()
244
+ last: str | int | None = None
245
+ while True:
246
+ code = server.popen.poll()
247
+ if code is not None:
248
+ return {"status": "exited", "seconds": round(time.monotonic() - t0, 3), "exit_code": code,
249
+ "last": last}
250
+ status, body = http_get(url, timeout=min(5.0, max(0.5, poll_s * 4)))
251
+ if status == 200:
252
+ return {"status": "ready", "seconds": round(time.monotonic() - t0, 3), "last": 200}
253
+ last = status if status is not None else str(body)[:200]
254
+ if time.monotonic() - t0 >= timeout_s:
255
+ return {"status": "timeout", "seconds": round(time.monotonic() - t0, 3), "last": last}
256
+ time.sleep(poll_s)
257
+
258
+
259
+ def salted_copy(messages: list | None, tag: str) -> list[dict]:
260
+ """``messages`` with ``[tag] `` put in front of the first message's text.
261
+
262
+ The tag sits as early in the prompt as the chat template allows, so a request with the
263
+ salted copy shares no cacheable prefix with the original (vLLM automatic prefix caching,
264
+ the SGLang radix cache and llama-server ``cache_prompt`` all match from the start).
265
+ """
266
+ msgs = [dict(m) for m in (messages or []) if isinstance(m, dict)] or [{"role": "user", "content": "Hello"}]
267
+ first = msgs[0]
268
+ content = first.get("content")
269
+ if isinstance(content, list): # multi-part content: a text part in front
270
+ first["content"] = [{"type": "text", "text": f"[{tag}] "}] + list(content)
271
+ else:
272
+ first["content"] = f"[{tag}] " + str(content if content is not None else "")
273
+ return msgs
274
+
275
+
276
+ WARMUP_KIND = ("salted copies of the prompt set's first prompts (unique prefix tag per request, so no timed "
277
+ "prompt's prefix is cached by the warm-up)")
278
+
279
+
280
+ def warmup(base_url: str, model: str | None, prompts: list[dict], n: int, max_tokens: int = 16,
281
+ timeout: float = 300.0, salt: str = "ik-rounds-warmup", chat_template_kwargs: dict | None = None) -> dict:
282
+ """Send ``n`` short, untimed chat requests to warm the server.
283
+
284
+ The requests are the first prompts of the set (so kernels for those shapes are warm),
285
+ each salted with ``[<salt>-<i>]`` in front of its first message: the warm-up must not
286
+ fill the prefix cache for the very prompts ``bench serve`` times next, which would turn
287
+ their TTFT into cache hits for engines with prefix caching only.
288
+ """
289
+ url = base_url.rstrip("/") + "/v1/chat/completions"
290
+ sent, ok, errors = 0, 0, []
291
+ for i, p in enumerate((prompts or [])[:max(0, n)]):
292
+ body = {"messages": salted_copy(p.get("messages"), f"{salt}-{i}"), "max_tokens": max_tokens,
293
+ "stream": False, "temperature": 0}
294
+ if model:
295
+ body["model"] = model
296
+ own = p.get("chat_template_kwargs")
297
+ kw = {**(chat_template_kwargs or {}), **(own if isinstance(own, dict) else {})}
298
+ if kw: # the same template settings bench serve sends (the warm-up must render the same prompt shape)
299
+ body["chat_template_kwargs"] = kw
300
+ status, resp = http_post_json(url, body, timeout)
301
+ sent += 1
302
+ if status == 200:
303
+ ok += 1
304
+ else:
305
+ errors.append(f"{status}: {str(resp)[:200]}")
306
+ return {"sent": sent, "ok": ok, "errors": errors[:5], "kind": WARMUP_KIND, "salt": salt}
307
+
308
+
309
+ # --------------------------------------------------------------------------- Prometheus /metrics
310
+
311
+ def scrape_metrics(base_url: str, timeout: float = 5.0) -> str | None:
312
+ """Raw Prometheus text from ``GET /metrics``, or None when the engine does not expose it."""
313
+ status, body = http_get(base_url.rstrip("/") + "/metrics", timeout)
314
+ if status != 200 or not body:
315
+ return None
316
+ text = body.decode(errors="replace") if isinstance(body, bytes) else str(body)
317
+ return text if ("# TYPE" in text or "# HELP" in text or "{" in text) else None
318
+
319
+
320
+ def parse_prometheus(text: str | None) -> dict:
321
+ """``{"types": {family: type}, "samples": {"name{labels}": value}}`` from Prometheus text format."""
322
+ types, samples = {}, {}
323
+ for line in (text or "").splitlines():
324
+ line = line.strip()
325
+ if not line:
326
+ continue
327
+ if line.startswith("#"):
328
+ parts = line.split()
329
+ if len(parts) >= 4 and parts[1] == "TYPE":
330
+ types[parts[2]] = parts[3]
331
+ continue
332
+ # name{labels} value [timestamp]; label values may contain spaces, so split at the closing brace.
333
+ if "}" in line:
334
+ key, _, rest = line.rpartition("}")
335
+ key += "}"
336
+ else:
337
+ key, _, rest = line.partition(" ")
338
+ fields = rest.split()
339
+ if not fields:
340
+ continue
341
+ try:
342
+ samples[key.strip()] = float(fields[0])
343
+ except ValueError:
344
+ continue
345
+ return {"types": types, "samples": samples}
346
+
347
+
348
+ def _family(sample_key: str, types: dict) -> tuple[str, str | None]:
349
+ name = sample_key.split("{", 1)[0]
350
+ if name in types:
351
+ return name, types[name]
352
+ for suffix in ("_total", "_count", "_sum", "_bucket", "_created"):
353
+ if name.endswith(suffix) and name[: -len(suffix)] in types:
354
+ return name[: -len(suffix)], types[name[: -len(suffix)]]
355
+ return name, None
356
+
357
+
358
+ def metrics_delta(before: str | None, after: str | None) -> dict:
359
+ """Counter and histogram increases between two scrapes (what the measured run did).
360
+
361
+ Gauges are reported at their ``after`` value. Series without a TYPE line are left out:
362
+ without the type we cannot tell a counter from a gauge.
363
+ """
364
+ if before is None or after is None:
365
+ return {"available": False}
366
+ b, a = parse_prometheus(before), parse_prometheus(after)
367
+ types = {**b["types"], **a["types"]}
368
+ counters, gauges = {}, {}
369
+ for key, value in a["samples"].items():
370
+ family, kind = _family(key, types)
371
+ if key.split("{", 1)[0].endswith("_created"):
372
+ continue
373
+ if kind in ("counter", "histogram", "summary"):
374
+ diff = value - b["samples"].get(key, 0.0)
375
+ if diff:
376
+ counters[key] = diff
377
+ elif kind == "gauge":
378
+ gauges[key] = value
379
+ return {"available": True, "counter_deltas": counters, "gauges_after": gauges}
380
+
381
+
382
+ # --------------------------------------------------------------------------- GPU memory
383
+
384
+ def gpu_memory_used_mib() -> list[int] | None:
385
+ """Used memory per visible GPU in MiB, or None without nvidia-smi."""
386
+ smi = which("nvidia-smi")
387
+ if not smi:
388
+ return None
389
+ r = run([smi, "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=30)
390
+ if not r.ok:
391
+ return None
392
+ out = []
393
+ for line in r.stdout.strip().splitlines():
394
+ try:
395
+ out.append(int(float(line.strip())))
396
+ except ValueError:
397
+ return None
398
+ return out or None
399
+
400
+
401
+ def wait_gpu_memory(baseline: list[int] | None, tolerance_mib: int, timeout_s: float,
402
+ reader=gpu_memory_used_mib) -> dict:
403
+ """Wait until every GPU is back within ``tolerance_mib`` of ``baseline`` (taken before launch)."""
404
+ if baseline is None:
405
+ return {"checked": False, "reason": "nvidia-smi not available"}
406
+ t0 = time.monotonic()
407
+ while True:
408
+ now = reader()
409
+ if now is None:
410
+ return {"checked": False, "reason": "nvidia-smi stopped answering", "baseline_mib": baseline}
411
+ excess = [max(0, n - b) for n, b in zip(now, baseline)]
412
+ if all(x <= tolerance_mib for x in excess):
413
+ return {"checked": True, "returned": True, "baseline_mib": baseline, "after_mib": now,
414
+ "seconds": round(time.monotonic() - t0, 3)}
415
+ if time.monotonic() - t0 >= timeout_s:
416
+ return {"checked": True, "returned": False, "baseline_mib": baseline, "after_mib": now,
417
+ "excess_mib": excess, "seconds": round(time.monotonic() - t0, 3)}
418
+ time.sleep(0.5)
419
+
420
+
421
+ # --------------------------------------------------------------------------- GPU lock
422
+
423
+ def default_lock_path() -> Path:
424
+ env = os.environ.get("INFERENCEKIT_GPU_LOCK")
425
+ if env:
426
+ return Path(env)
427
+ return Path(os.path.expanduser("~")) / ".cache" / "inferencekit" / "gpu.lock"
428
+
429
+
430
+ @contextlib.contextmanager
431
+ def gpu_lock(path: str | os.PathLike | None, owner: str):
432
+ """Hold an exclusive, non-blocking ``flock`` on ``path`` for the duration of the block.
433
+
434
+ The kernel drops the lock if this process dies, so a crashed run never leaves a stale
435
+ lock behind. The file holds the current holder's pid and owner string, for the error
436
+ message another run prints.
437
+ """
438
+ p = Path(path) if path else default_lock_path()
439
+ p.parent.mkdir(parents=True, exist_ok=True)
440
+ fd = os.open(p, os.O_RDWR | os.O_CREAT, 0o644)
441
+ try:
442
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
443
+ except BlockingIOError:
444
+ try:
445
+ holder = os.pread(fd, 4096, 0).decode(errors="replace").strip() or "unknown holder"
446
+ finally:
447
+ os.close(fd)
448
+ raise OpError(f"GPU lock {p} is held by {holder}: another run is using the GPU. Wait for it, or pass "
449
+ "a different --gpu-lock only if the runs use different GPUs.") from None
450
+ info = {"pid": os.getpid(), "owner": owner, "since": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
451
+ try:
452
+ os.ftruncate(fd, 0)
453
+ os.pwrite(fd, json.dumps(info).encode(), 0)
454
+ yield {"path": str(p), **info}
455
+ finally:
456
+ with contextlib.suppress(OSError):
457
+ os.ftruncate(fd, 0)
458
+ fcntl.flock(fd, fcntl.LOCK_UN)
459
+ os.close(fd)
460
+
461
+
462
+ # --------------------------------------------------------------------------- resolved configuration from logs
463
+ #
464
+ # What an engine *actually* ran with is what it logged, not what the launch command asked
465
+ # for: engines silently fall back (attention backend), clamp (context length, KV cache) or
466
+ # ignore settings. Each parser below reads the server log for the lines the engine prints
467
+ # about its resolved settings. Patterns were checked against the upstream sources on
468
+ # 13 Oct 2026 and kept tolerant (anywhere in the line, any prefix):
469
+ # vLLM vllm/entrypoints/serve/utils/api_utils.py "non-default args: %s"
470
+ # vllm/v1/engine/core.py "Initializing a V1 LLM engine (v%s) with config: %s"
471
+ # vllm/platforms/cuda.py "Using %s backend." / "Using %s attention backend out of ..."
472
+ # vllm/v1/worker/gpu_model_runner.py "Capturing CUDA graphs (%s, %s)", "Graph capturing finished
473
+ # in %.0f secs, took %.2f GiB", "Model loading took ..."
474
+ # vllm/v1/core/kv_cache_utils.py "%s KV cache size: %s tokens", "Maximum concurrency for ..."
475
+ # vllm/platforms/interface.py "Unknown vLLM environment variable detected: %s"
476
+ # SGLang entrypoints/engine.py "server_args={...}" (older releases: "server_args=ServerArgs(...)"),
477
+ # managers/scheduler.py "max_total_num_tokens=..., chunked_prefill_size=..., ...",
478
+ # model_runner.py "Load weight end. ...", older "Capture cuda graph end. Time elapsed: ..."
479
+ # llama.cpp src/llama-context.cpp "<fn>: n_ctx = %u", "<fn>: flash_attn = %s" (auto/enabled/disabled; older 0/1),
480
+ # src/llama-model.cpp "<fn>: offloaded %d/%d layers to GPU", src/llama-kv-cache.cpp
481
+ # "... K (%s): %7.2f MiB, V (%s): ...", common "build: %d (%s) ...", "system_info: ..."
482
+ # TRT-LLM _torch/pyexecutor/py_executor_creator.py "LLM Args:\n%s" (pydantic repr), "[TRT-LLM] [W] ..."
483
+ # Anything else (or a format change) leaves the setting out; with nothing found the
484
+ # resolved config is UNPROVEN and the raw log stays in the run directory.
485
+ #
486
+ # A custom engine (engine kind ``generic``) can also print one line
487
+ # {"inferencekit_resolved_config": {...}}
488
+ # Those settings are SELF_REPORTED: the process under test says so, nothing checks it. A
489
+ # self-report can reveal a hard-gate violation (e.g. ``"cuda_graphs": false``) but never
490
+ # proves there is none, and for a known engine kind (vllm, sglang, llamacpp, trtllm) it is
491
+ # kept for reference only: it never changes the status or the hard-gate flags, which come
492
+ # from the engine's own log lines.
493
+
494
+ ENGINE_KINDS = ("vllm", "sglang", "llamacpp", "trtllm", "generic")
495
+ _ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
496
+ _WARN = re.compile(r"\bWARN(?:ING)?\b|\[W\]|\[TRT-LLM\]\[WARNING\]|\bWarning:|UserWarning|DeprecationWarning|"
497
+ r"FutureWarning")
498
+ _ERR = re.compile(r"\bERROR\b|\bCRITICAL\b|Traceback \(most recent call last\)|\berror:|\[E\]")
499
+ MAX_LINES = 100
500
+
501
+
502
+ def engine_kind(name: str | None, launch: list[str] | None = None) -> str:
503
+ """Map a manifest engine (``name`` and launch argv) to a log parser."""
504
+ hay = " ".join([str(name or "")] + [str(a) for a in (launch or [])[:3]]).lower()
505
+ if "vllm" in hay:
506
+ return "vllm"
507
+ if "sglang" in hay:
508
+ return "sglang"
509
+ if "llama" in hay and ("cpp" in hay or "server" in hay or "llama.cpp" in hay):
510
+ return "llamacpp"
511
+ if "trtllm" in hay or "tensorrt" in hay or "trt-llm" in hay:
512
+ return "trtllm"
513
+ return "generic"
514
+
515
+
516
+ def _scalar(text: str):
517
+ """'True' -> True, '4096' -> 4096, "'auto'" -> 'auto', 'torch.bfloat16' stays a string."""
518
+ t = text.strip()
519
+ if len(t) >= 2 and t[0] == t[-1] and t[0] in "'\"":
520
+ return t[1:-1]
521
+ low = t.lower()
522
+ if low in ("true", "false"):
523
+ return low == "true"
524
+ if low in ("none", "null"):
525
+ return None
526
+ try:
527
+ return int(t)
528
+ except ValueError:
529
+ pass
530
+ try:
531
+ return float(t)
532
+ except ValueError:
533
+ return t
534
+
535
+
536
+ def split_top_level(text: str, sep: str = ",") -> list[str]:
537
+ """Split on ``sep`` outside brackets, braces, parentheses, angle brackets and quotes."""
538
+ parts, depth, quote, buf = [], 0, None, []
539
+ for ch in text:
540
+ if quote:
541
+ buf.append(ch)
542
+ if ch == quote:
543
+ quote = None
544
+ continue
545
+ if ch in "'\"":
546
+ quote = ch
547
+ elif ch in "([{<":
548
+ depth += 1
549
+ elif ch in ")]}>":
550
+ depth = max(0, depth - 1)
551
+ elif ch == sep and depth == 0:
552
+ parts.append("".join(buf))
553
+ buf = []
554
+ continue
555
+ buf.append(ch)
556
+ if "".join(buf).strip():
557
+ parts.append("".join(buf))
558
+ return parts
559
+
560
+
561
+ def parse_kv_pairs(text: str, sep: str = ",") -> dict:
562
+ """``a=1, b='x', c={...}`` -> {"a": 1, "b": "x", "c": "{...}"} (nested values kept as text)."""
563
+ out = {}
564
+ for part in split_top_level(text, sep):
565
+ key, eq, value = part.strip().partition("=")
566
+ if eq and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]*", key.strip()):
567
+ out[key.strip()] = _scalar(value)
568
+ return out
569
+
570
+
571
+ def _literal_dict(text: str) -> dict | None:
572
+ try:
573
+ value = ast.literal_eval(text)
574
+ except (ValueError, SyntaxError, MemoryError, RecursionError):
575
+ return None
576
+ return value if isinstance(value, dict) else None
577
+
578
+
579
+ def _lines(text: str) -> list[str]:
580
+ return [_ANSI.sub("", ln).rstrip() for ln in text.splitlines()]
581
+
582
+
583
+ def _collect(lines: list[str], rx: re.Pattern, limit: int = MAX_LINES) -> list[str]:
584
+ seen, out = set(), []
585
+ for ln in lines:
586
+ if rx.search(ln):
587
+ key = re.sub(r"^\S*\s*(INFO|WARNING|ERROR)?\s*\d\d-\d\d \d\d:\d\d:\d\d\S*\s*", "", ln)[:400]
588
+ if key not in seen:
589
+ seen.add(key)
590
+ out.append(ln[:400])
591
+ if len(out) >= limit:
592
+ break
593
+ return out
594
+
595
+
596
+ def _last(rx: re.Pattern, lines: list[str]):
597
+ m = None
598
+ for ln in lines:
599
+ found = rx.search(ln)
600
+ if found:
601
+ m = found
602
+ return m
603
+
604
+
605
+ def _custom(lines: list[str]) -> dict | None:
606
+ for ln in reversed(lines):
607
+ i = ln.find('{"inferencekit_resolved_config"')
608
+ if i >= 0:
609
+ try:
610
+ value = json.loads(ln[i:])["inferencekit_resolved_config"]
611
+ if isinstance(value, dict):
612
+ return value
613
+ except (ValueError, KeyError):
614
+ continue
615
+ return None
616
+
617
+
618
+ # CUDAGraphMode values (vllm/config/compilation.py): NONE=0, PIECEWISE=1, FULL=2, and the pairs
619
+ # FULL_DECODE_ONLY=(FULL, NONE), FULL_AND_PIECEWISE=(FULL, PIECEWISE). JSON dumps print the numbers.
620
+ _CG_NUMERIC = {"0": "NONE", "1": "PIECEWISE", "2": "FULL", "2,0": "FULL_DECODE_ONLY", "2,1": "FULL_AND_PIECEWISE"}
621
+
622
+
623
+ def _cudagraph_mode(comp: str) -> str | None:
624
+ m = re.search(r"['\"]cudagraph_mode['\"]:\s*(?:<CUDAGraphMode\.([A-Z_]+)|['\"]?([A-Z][A-Z_]+)|"
625
+ r"[\[(]\s*(\d)\s*,\s*(\d)\s*[\])]|(\d))", comp)
626
+ if not m:
627
+ return None
628
+ if m.group(1) or m.group(2):
629
+ return m.group(1) or m.group(2)
630
+ if m.group(3) is not None:
631
+ return _CG_NUMERIC.get(f"{m.group(3)},{m.group(4)}", f"({m.group(3)}, {m.group(4)})")
632
+ return _CG_NUMERIC.get(m.group(5), m.group(5))
633
+
634
+
635
+ def _vllm(lines: list[str], out: dict) -> None:
636
+ s, found = out["settings"], out["matched"]
637
+ m = _last(re.compile(r"Initializing a V1 LLM engine \(v([^)]+)\) with config: (.*)$"), lines)
638
+ if m:
639
+ out["version"] = m.group(1)
640
+ cfg = parse_kv_pairs(m.group(2))
641
+ keep = ("model", "served_model_name", "revision", "dtype", "max_seq_len", "quantization", "enforce_eager",
642
+ "kv_cache_dtype", "tensor_parallel_size", "pipeline_parallel_size", "data_parallel_size",
643
+ "enable_prefix_caching", "enable_chunked_prefill", "seed", "load_format", "speculative_config")
644
+ s["engine_config"] = {k: cfg[k] for k in keep if k in cfg}
645
+ comp = str(cfg.get("compilation_config") or "")
646
+ mode = _cudagraph_mode(comp)
647
+ if mode:
648
+ s["cudagraph_mode"] = mode
649
+ sizes = re.search(r"['\"]cudagraph_capture_sizes['\"]:\s*\[([^\]]*)\]", comp)
650
+ if sizes:
651
+ s["cudagraph_capture_sizes_count"] = len([x for x in sizes.group(1).split(",") if x.strip()])
652
+ level = re.search(r"['\"](?:level|mode)['\"]:\s*(?:<CompilationMode\.)?([A-Z_0-9]+)", comp)
653
+ if level:
654
+ s["compilation_mode"] = level.group(1)
655
+ found.append("engine_config")
656
+ if not out.get("version"):
657
+ v = _last(re.compile(r"vLLM (?:API server )?version:? v?([0-9][\w.+-]*)"), lines)
658
+ if v:
659
+ out["version"] = v.group(1)
660
+ m = _last(re.compile(r"non-default args: (\{.*\})\s*$"), lines)
661
+ if m:
662
+ parsed = _literal_dict(m.group(1))
663
+ s["non_default_args"] = parsed if parsed is not None else m.group(1)[:2000]
664
+ found.append("non_default_args")
665
+ from inferencekit.engines.gates import attention_backend_from_log
666
+
667
+ backend = attention_backend_from_log("\n".join(lines))
668
+ if backend:
669
+ s["attention_backend"] = backend
670
+ found.append("attention_backend")
671
+ caps = [m.group(1, 2) for m in (re.search(r"Capturing CUDA graphs \(([^,]+), (\w+)\)", ln) for ln in lines) if m]
672
+ if caps:
673
+ s["cuda_graph_captures"] = sorted({f"{a} {b}" for a, b in caps})
674
+ m = _last(re.compile(r"Graph capturing finished in ([\d.]+) secs?, took (-?[\d.]+) GiB"), lines)
675
+ if m:
676
+ s["cuda_graphs"] = {"captured": True, "seconds": float(m.group(1)), "gib": float(m.group(2))}
677
+ found.append("cuda_graphs")
678
+ m = _last(re.compile(r"(?:(\w+) )?KV cache size: ([\d,]+) tokens"), lines)
679
+ if m:
680
+ s["kv_cache_tokens"] = int(m.group(2).replace(",", ""))
681
+ found.append("kv_cache")
682
+ m = _last(re.compile(r"Maximum concurrency for ([\d,]+) tokens per request: ([\d.]+)x"), lines)
683
+ if m:
684
+ s["max_concurrency"] = {"tokens_per_request": int(m.group(1).replace(",", "")), "x": float(m.group(2))}
685
+ m = _last(re.compile(r"Available KV cache memory: ([\d.]+) GiB"), lines)
686
+ if m:
687
+ s["kv_cache_memory_gib"] = float(m.group(1))
688
+ m = _last(re.compile(r"Model loading took ([\d.]+) GiB(?: memory)? and ([\d.]+) s"), lines)
689
+ if m:
690
+ s["model_memory_gib"] = float(m.group(1))
691
+ for ln in lines:
692
+ u = re.search(r"Unknown vLLM environment variable detected: (\S+)", ln)
693
+ if u:
694
+ out["unknown_settings"].append(f"env {u.group(1)}")
695
+ u = re.search(r"error: unrecognized arguments: (.*)$", ln)
696
+ if u:
697
+ out["unknown_settings"].append(f"args {u.group(1).strip()}")
698
+ flags = {}
699
+ cfg = s.get("engine_config", {})
700
+ if "enforce_eager" in cfg:
701
+ flags["enforce_eager"] = cfg["enforce_eager"]
702
+ if "cudagraph_mode" in s:
703
+ flags["cudagraph_mode"] = s["cudagraph_mode"]
704
+ if s.get("cudagraph_capture_sizes_count") is not None:
705
+ flags["cudagraph_capture_sizes"] = {"count": s["cudagraph_capture_sizes_count"]}
706
+ out["_gates"] = ("vllm", flags, backend)
707
+ core = {"engine_config", "attention_backend"}
708
+ out["status"] = "RESOLVED" if core <= set(found) else "PARTIAL" if found else "UNPROVEN"
709
+
710
+
711
+ def _sglang(lines: list[str], out: dict) -> None:
712
+ s, found = out["settings"], out["matched"]
713
+ m = _last(re.compile(r"server_args=(\{.*\}|ServerArgs\(.*\))\s*$"), lines)
714
+ if m:
715
+ raw = m.group(1)
716
+ if raw.startswith("{"):
717
+ args = _literal_dict(raw)
718
+ if args is None: # repr of non-literal values: fall back to "'key': value" pairs
719
+ args = {k: _scalar(v) for k, v in re.findall(r"'(\w+)':\s*('[^']*'|[^,}]+)", raw)}
720
+ else:
721
+ args = parse_kv_pairs(raw[len("ServerArgs("):-1])
722
+ s["server_args"] = args
723
+ found.append("server_args")
724
+ m = _last(re.compile(r"(max_total_num_tokens=.*)$"), lines)
725
+ if m:
726
+ s["scheduler"] = {k: v for k, v in parse_kv_pairs(m.group(1)).items()}
727
+ found.append("scheduler")
728
+ m = _last(re.compile(r"Load weight end\.(.*)$"), lines)
729
+ if m:
730
+ s["load_weight"] = {k: v for k, v in parse_kv_pairs(m.group(1).rstrip(".")).items()}
731
+ m = _last(re.compile(r"Capture (?:cuda|CUDA) graph end\. Time elapsed: ([\d.]+) s"), lines)
732
+ if m:
733
+ s["cuda_graphs"] = {"captured": True, "seconds": float(m.group(1))}
734
+ found.append("cuda_graphs")
735
+ m = _last(re.compile(r"KV Cache is allocated\. #tokens: (\d+)"), lines)
736
+ if m:
737
+ s["kv_cache_tokens"] = int(m.group(1))
738
+ v = _last(re.compile(r"\bsglang(?:[ =]+version)?[:= ]+v?(\d+\.\d+\.\d+[\w.+-]*)", re.IGNORECASE), lines)
739
+ if v:
740
+ out["version"] = v.group(1)
741
+ for ln in lines:
742
+ u = re.search(r"error: unrecognized arguments: (.*)$", ln)
743
+ if u:
744
+ out["unknown_settings"].append(f"args {u.group(1).strip()}")
745
+ args = s.get("server_args") or {}
746
+ flags = {k: args[k] for k in ("disable_cuda_graph",) if k in args}
747
+ backend = args.get("attention_backend") or args.get("decode_attention_backend")
748
+ out["_gates"] = ("sglang", flags, str(backend) if backend else None)
749
+ out["status"] = "RESOLVED" if "server_args" in found else "PARTIAL" if found else "UNPROVEN"
750
+
751
+
752
+ def _llamacpp(lines: list[str], out: dict) -> None:
753
+ s, found = out["settings"], out["matched"]
754
+ m = _last(re.compile(r"\bbuild:\s*(\d+)\s*\(([0-9a-f]+)\)"), lines)
755
+ if m:
756
+ out["version"] = f"b{m.group(1)} ({m.group(2)})"
757
+ found.append("build")
758
+ m = _last(re.compile(r"system_info:\s*(.*)$"), lines)
759
+ if m:
760
+ s["system_info"] = m.group(1)[:600]
761
+ s["gpu_backend_in_build"] = bool(re.search(r"\b(CUDA|Vulkan|Metal|HIP|ROCm|SYCL|MUSA)\b", m.group(1)))
762
+ g = re.search(r"USE_GRAPHS\s*=\s*(\d)", m.group(1))
763
+ if g:
764
+ s["cuda_graphs_compiled"] = g.group(1) == "1"
765
+ ctx = {}
766
+ keys = ("n_ctx", "n_ctx_seq", "n_ctx_per_seq", "n_batch", "n_ubatch", "n_seq_max", "flash_attn", "causal_attn",
767
+ "kv_unified", "freq_base", "freq_scale")
768
+ rx = re.compile(r"(?:llama_context|llama_new_context_with_model|llama_init_from_model)\s*:\s*(\w+)\s*=\s*(\S+)")
769
+ for ln in lines:
770
+ c = rx.search(ln)
771
+ if c and c.group(1) in keys:
772
+ ctx[c.group(1)] = _scalar(c.group(2))
773
+ if ctx:
774
+ s["context"] = ctx
775
+ found.append("context")
776
+ m = _last(re.compile(r"offloaded (\d+)/(\d+) layers to GPU"), lines)
777
+ if m:
778
+ s["offloaded_layers"] = {"gpu": int(m.group(1)), "total": int(m.group(2))}
779
+ found.append("offload")
780
+ m = _last(re.compile(r"K \((\w+)\):\s*[\d.]+ MiB, V \((\w+)\)"), lines) or \
781
+ _last(re.compile(r"type_k = '(\w+)', type_v = '(\w+)'"), lines)
782
+ if m:
783
+ s["kv_cache_type"] = {"k": m.group(1), "v": m.group(2)}
784
+ m = _last(re.compile(r"Device \d+: (.+?), compute capability ([\d.]+)"), lines)
785
+ if m:
786
+ s["device"] = {"name": m.group(1), "compute_capability": m.group(2)}
787
+ m = _last(re.compile(r"n_slots\s*=\s*(\d+)"), lines)
788
+ if m:
789
+ s["n_slots"] = int(m.group(1))
790
+ for ln in lines:
791
+ u = re.search(r"error: (?:invalid|unknown) argument:? (\S+)", ln)
792
+ if u:
793
+ out["unknown_settings"].append(f"args {u.group(1)}")
794
+ viol = []
795
+ off = s.get("offloaded_layers")
796
+ if off and off["gpu"] < off["total"]:
797
+ viol.append(f"only {off['gpu']}/{off['total']} layers offloaded to the GPU")
798
+ if s.get("gpu_backend_in_build") is False:
799
+ viol.append("no GPU backend in this llama.cpp build (system_info)")
800
+ out["validity"] = {"valid": (not viol) if off or "system_info" in s else None, "violations": viol,
801
+ "warnings": [], "checked": [c for c, k in (("gpu_offload", "offloaded_layers"),
802
+ ("gpu_backend", "system_info")) if k in s]}
803
+ core = {"build", "context", "offload"}
804
+ out["status"] = "RESOLVED" if core <= set(found) else "PARTIAL" if found else "UNPROVEN"
805
+
806
+
807
+ def _trtllm(lines: list[str], out: dict) -> None:
808
+ s, found = out["settings"], out["matched"]
809
+ v = _last(re.compile(r"TensorRT-LLM version:? v?([0-9][\w.+-]*)"), lines)
810
+ if v:
811
+ out["version"] = v.group(1)
812
+ text = "\n".join(lines)
813
+ i = text.find("LLM Args:")
814
+ if i >= 0:
815
+ dump = text[i: i + 20000]
816
+ args = {}
817
+ for key in ("backend", "model", "dtype", "max_batch_size", "max_num_tokens", "max_seq_len", "max_input_len",
818
+ "max_beam_width", "attn_backend", "tensor_parallel_size", "enable_chunked_prefill",
819
+ "enable_attention_dp", "disable_overlap_scheduler"):
820
+ m = re.search(rf"\b{key}=('[^']*'|[^\s,)]+)", dump)
821
+ if m:
822
+ args[key] = _scalar(m.group(1))
823
+ m = re.search(r"\bcuda_graph_config=(None|CudaGraphConfig\()", dump)
824
+ if m:
825
+ args["cuda_graphs"] = m.group(1) != "None"
826
+ m = re.search(r"kv_cache_config=KvCacheConfig\((.*?)\)", dump, re.DOTALL)
827
+ if m:
828
+ kv = {k: _scalar(v) for k, v in re.findall(r"\b(dtype|free_gpu_memory_fraction|max_tokens|"
829
+ r"enable_block_reuse)=('[^']*'|[^\s,)]+)", m.group(1))}
830
+ if kv:
831
+ args["kv_cache_config"] = kv
832
+ s["llm_args"] = args
833
+ if args:
834
+ found.append("llm_args")
835
+ flags = {}
836
+ if "cuda_graphs" in (s.get("llm_args") or {}):
837
+ flags["cuda_graphs"] = s["llm_args"]["cuda_graphs"]
838
+ backend = (s.get("llm_args") or {}).get("attn_backend")
839
+ out["_gates"] = ("cmd", flags, str(backend) if backend else None)
840
+ out["status"] = "RESOLVED" if "llm_args" in found and out.get("version") else "PARTIAL" if found or \
841
+ out.get("version") else "UNPROVEN"
842
+
843
+
844
+ _PARSERS = {"vllm": _vllm, "sglang": _sglang, "llamacpp": _llamacpp, "trtllm": _trtllm}
845
+
846
+
847
+ STATUS_RANK = {"RESOLVED": 0, "PARTIAL": 1, "SELF_REPORTED": 2, "UNPROVEN": 3}
848
+
849
+
850
+ def parse_resolved_config(kind: str, log_text: str) -> dict:
851
+ """Settings an engine reported it resolved, from its server log.
852
+
853
+ Returns ``{"engine_kind", "status": RESOLVED | PARTIAL | SELF_REPORTED | UNPROVEN, "version",
854
+ "settings", "matched", "validity", "warnings", "errors", "unknown_settings", "self_reported"}``.
855
+ RESOLVED means the engine's own configuration dump was found; PARTIAL, only some of
856
+ the lines we look for; SELF_REPORTED, only an ``inferencekit_resolved_config`` line from a
857
+ generic (custom) engine; UNPROVEN, nothing (an unknown engine or a changed log format).
858
+ """
859
+ from inferencekit.engines import gates
860
+
861
+ lines = _lines(log_text or "")
862
+ out: dict = {"engine_kind": kind if kind in ENGINE_KINDS else "generic", "status": "UNPROVEN", "version": None,
863
+ "settings": {}, "matched": [], "validity": None, "unknown_settings": [], "self_reported": []}
864
+ parser = _PARSERS.get(out["engine_kind"])
865
+ if parser is not None:
866
+ parser(lines, out)
867
+ custom = _custom(lines)
868
+ if custom is not None:
869
+ out["settings"]["self_reported"] = custom
870
+ out["self_reported"] = sorted(str(k) for k in custom)
871
+ if parser is None:
872
+ out["matched"].append("self_reported")
873
+ out["status"] = "SELF_REPORTED"
874
+ else: # a known engine: its own log lines decide; the self-report is only kept
875
+ out["matched"].append("self_reported (ignored: the engine's own log lines decide)")
876
+ g = out.pop("_gates", None)
877
+ if g is not None:
878
+ engine, flags, backend = g
879
+ out["validity"] = gates.check(engine, flags, backend)
880
+ elif out["validity"] is None and custom is not None and parser is None:
881
+ v = gates.check("cmd", custom, custom.get("attention_backend"))
882
+ v["source"] = "self_reported"
883
+ if not v.get("violations"):
884
+ v["valid"] = None # a self-report can show a violation, never prove there is none
885
+ v["warnings"] = list(v.get("warnings") or []) + ["settings self-reported by the engine process "
886
+ "(inferencekit_resolved_config line): not verified"]
887
+ out["validity"] = v
888
+ out["warnings"] = _collect(lines, _WARN)
889
+ out["errors"] = _collect(lines, _ERR, 50)
890
+ out["unknown_settings"] = sorted(set(out["unknown_settings"]))
891
+ if out["status"] == "UNPROVEN":
892
+ out["reason"] = (f"no resolved-configuration lines recognised for engine kind '{out['engine_kind']}'; "
893
+ "the raw log is kept")
894
+ elif out["status"] == "SELF_REPORTED":
895
+ out["reason"] = ("settings come only from the engine process's own inferencekit_resolved_config line "
896
+ "(self-reported, not an engine's configuration dump); the raw log is kept")
897
+ return out