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,351 @@
1
+ """Clients for video clip engines: the clip HTTP contract, and the interface other backends implement.
2
+
3
+ The clip HTTP contract
4
+ ----------------------
5
+ GET /health 200 when the engine can take a clip (the ready check of bench rounds' clip cells).
6
+ POST /v1/clips JSON body:
7
+ task "t2v" | "i2v"
8
+ prompt the text prompt
9
+ image i2v only: the conditioning image, base64 of the image file's bytes
10
+ width, height, num_frames, fps, steps, guidance_scale, seed
11
+ noise_seed optional: seed of the initial noise, when it is given apart from ``seed``
12
+ audio optional: true/false, sent only when the clip set says so
13
+ The answer is JSON:
14
+ video_b64 the encoded clip, base64; or
15
+ video_path a file the server wrote on this machine (accepted only from a local server); the
16
+ answer must then also carry video_sha256 and video_bytes
17
+ video_sha256 sha256 (hex) and size in bytes of the finished clip: required with video_path, checked
18
+ video_bytes whenever given (``sha256`` and ``bytes`` are read as the same two fields)
19
+ format container, e.g. "mp4" (default "mp4"); letters and digits only, else "bin"
20
+ stage_ms {stage: ms}, the engine's own timings (SELF-REPORTED): e.g. text_encode, denoise,
21
+ vae_decode, audio_decode, mux. Disjoint stages; a "total"/"e2e"/"wall" key is kept but
22
+ not summed. Lists (e.g. per-step times) are kept and not summed either.
23
+ effective what the engine says it ran (SELF-REPORTED): steps_run, width, height, frames, fps,
24
+ dtype, attention, audio
25
+ cache optional: names of stages the engine served from a cache (must be empty for a timed clip),
26
+ except "negative_text_encode": the encoding of the engine's fixed negative prompt (a
27
+ launch setting, the same text for every clip) computed once and reused. That one reuse
28
+ is allowed for every engine (ComfyUI makes it through its node cache) and stated in the
29
+ record's limitations; anything else listed makes the claim invalid.
30
+ An answer may instead be the raw clip (Content-Type video/*), with the other fields as JSON in an
31
+ ``X-Clip-Meta`` header. The subtype (video/mp4 -> "mp4") is a format like any other: it names the saved
32
+ file, so anything but letters and digits becomes "bin".
33
+
34
+ The timer
35
+ ---------
36
+ The client's clock is the only timer for published numbers. ``t_send`` is taken just before the
37
+ connection is opened, ``t_done`` once the client holds the whole clip: the response is read and its
38
+ base64 decoded, or the file at ``video_path`` is read. A server cannot answer before the clip is
39
+ complete and finish it later: the bytes of a raw or base64 answer are the response itself, and the file
40
+ behind ``video_path`` must match the video_sha256 and video_bytes the answer announced, so a server that
41
+ answers while it is still encoding, muxing or writing the file gets a failed clip, not a shorter time (a
42
+ server closes the file before it answers). That check, hashing and saving happen after ``t_done``, so
43
+ they are not timed. Every clock is ``time.perf_counter_ns()`` (monotonic).
44
+
45
+ Other backends
46
+ --------------
47
+ ``ClipBackend`` is the interface bench clip drives: ``health()``, ``generate(request)``, ``describe()``,
48
+ ``close()``. ``ClipHTTP`` implements the contract above (endpoint kind ``clip_http``). A ComfyUI
49
+ backend (endpoint kind ``comfyui``) lives in ``inferencekit.engines.clip.comfyui`` when installed; that
50
+ module calls ``register_backend("comfyui", factory)`` on import, and ``make_backend`` imports it on
51
+ first use. A ComfyUI backend must keep the same timer (``t_send`` before POST /prompt, ``t_done`` when
52
+ the output bytes are in hand from /view), put the node ids ComfyUI reports in ``execution_cached`` for
53
+ timed nodes into ``ClipResult.cache`` (a reused fixed negative-prompt encoding into ``cache_allowed``),
54
+ and fill ``effective`` from what it submitted and the progress it saw (steps_run = sampler progress
55
+ steps seen). Its ``workflow`` option is the API-format workflow
56
+ file with the node mapping; ``describe()`` returns that file's sha256.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import base64
62
+ import binascii
63
+ import hashlib
64
+ import http.client
65
+ import importlib
66
+ import json
67
+ import time
68
+ from dataclasses import dataclass, field
69
+ from pathlib import Path
70
+ from typing import Callable, Protocol
71
+
72
+ from inferencekit.serve.client import Endpoint, scrub
73
+
74
+ TASKS = ("t2v", "i2v")
75
+ ENDPOINT_KINDS = ("clip_http", "comfyui") # what bench clip can drive; in_process is for ops that run the pipeline
76
+ CLIPS_PATH = "/v1/clips"
77
+ HEALTH_PATH = "/health"
78
+ DEFAULT_TIMEOUT_S = 1800.0
79
+ MAX_ERROR_BODY = 2000
80
+ TOTAL_KEYS = ("total", "total_ms", "e2e", "e2e_ms", "wall", "wall_ms") # in stage_ms, but not stages
81
+ NEGATIVE_PROMPT_CACHE = "negative_text_encode" # the fixed negative prompt's encoding, computed once and reused
82
+ ALLOWED_CACHE = frozenset({NEGATIVE_PROMPT_CACHE}) # reuse every engine may report without invalidating a clip
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ClipRequest:
87
+ """One clip to generate: a clip-set row plus the round's seed."""
88
+
89
+ id: str
90
+ task: str
91
+ prompt: str
92
+ width: int
93
+ height: int
94
+ num_frames: int
95
+ fps: float
96
+ steps: int
97
+ guidance_scale: float
98
+ seed: int
99
+ image: bytes | None = field(default=None, repr=False) # i2v: the conditioning image file's bytes
100
+ noise_seed: int | None = None
101
+ audio: bool | None = None
102
+
103
+ def body(self) -> dict:
104
+ """The JSON body of POST /v1/clips."""
105
+ b = {"task": self.task, "prompt": self.prompt, "width": self.width, "height": self.height,
106
+ "num_frames": self.num_frames, "fps": self.fps, "steps": self.steps,
107
+ "guidance_scale": self.guidance_scale, "seed": self.seed}
108
+ if self.image is not None:
109
+ b["image"] = base64.b64encode(self.image).decode("ascii")
110
+ if self.noise_seed is not None:
111
+ b["noise_seed"] = self.noise_seed
112
+ if self.audio is not None:
113
+ b["audio"] = self.audio
114
+ return b
115
+
116
+
117
+ @dataclass
118
+ class ClipResult:
119
+ id: str
120
+ ok: bool = False
121
+ error: str | None = None
122
+ status: int | None = None
123
+ t_send_ns: int = 0
124
+ t_connected_ns: int | None = None
125
+ t_done_ns: int | None = None # the whole clip is in the client's hands
126
+ t_end_ns: int = 0
127
+ video: bytes | None = field(default=None, repr=False)
128
+ format: str = "mp4"
129
+ stage_ms: dict | None = None # self-reported
130
+ effective: dict | None = None # self-reported
131
+ cache: list | None = None # stages served from a cache: any makes the claim invalid
132
+ cache_allowed: list | None = None # reuse that is allowed and stated (ALLOWED_CACHE)
133
+ via: str | None = None # how the clip arrived: video_b64, video_path or body
134
+
135
+ @property
136
+ def e2e_ms(self) -> float | None:
137
+ return (self.t_done_ns - self.t_send_ns) / 1e6 if self.ok and self.t_done_ns is not None else None
138
+
139
+ @property
140
+ def connect_ms(self) -> float | None:
141
+ return (self.t_connected_ns - self.t_send_ns) / 1e6 if self.t_connected_ns is not None else None
142
+
143
+
144
+ class ClipBackend(Protocol):
145
+ """What bench clip drives. ``generate`` never raises for engine failures: it returns ok=False."""
146
+
147
+ endpoint_kind: str
148
+
149
+ def health(self) -> tuple[int | None, str]:
150
+ """(HTTP status or None, short detail): 200 means ready."""
151
+
152
+ def generate(self, request: ClipRequest) -> ClipResult:
153
+ """Generate one clip, client-timed (see the module docstring for the timer)."""
154
+
155
+ def describe(self) -> dict:
156
+ """Facts for the evidence record, e.g. {"workflow_sha256": ...}; never secrets or host names."""
157
+
158
+ def close(self) -> None:
159
+ ...
160
+
161
+
162
+ def stage_sum_ms(stage_ms: dict | None) -> float | None:
163
+ """Sum of the numeric stages (totals and per-step lists left out); None when there are none."""
164
+ if not isinstance(stage_ms, dict):
165
+ return None
166
+ vals = [float(v) for k, v in stage_ms.items() if k not in TOTAL_KEYS and isinstance(v, (int, float))
167
+ and not isinstance(v, bool)]
168
+ return sum(vals) if vals else None
169
+
170
+
171
+ def _clip_format(value) -> str:
172
+ """The engine's format name as a file extension: up to 8 ASCII letters and digits, else "bin"."""
173
+ f = str(value or "mp4").lower().lstrip(".")
174
+ return f if f.isascii() and f.isalnum() and len(f) <= 8 else "bin"
175
+
176
+
177
+ def _announced(doc: dict) -> tuple | None:
178
+ """(sha256, bytes) of the finished clip as the answer states them, or None when it states neither."""
179
+ sha = doc.get("video_sha256", doc.get("sha256"))
180
+ size = doc.get("video_bytes", doc.get("bytes"))
181
+ return None if sha is None and size is None else (sha, size)
182
+
183
+
184
+ def _mismatch(video: bytes, announced: tuple, via: str | None) -> str | None:
185
+ """Why the clip in hand is not the one the answer announced (None when it is)."""
186
+ sha, size = announced
187
+ bad = []
188
+ if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size != len(video)):
189
+ bad.append(f"{len(video)} bytes in hand, {str(size)[:20]} announced")
190
+ if sha is not None and (not isinstance(sha, str) or hashlib.sha256(video).hexdigest() != sha.strip().lower()):
191
+ bad.append("its sha256 is not the announced one")
192
+ if not bad:
193
+ return None
194
+ late = ": the server answered before the file was complete" if via == "video_path" else ""
195
+ return f"the clip in hand is not the one the answer describes ({'; '.join(bad)}){late}"
196
+
197
+
198
+ class ClipHTTP:
199
+ """The clip HTTP contract over plain http.client (one connection per request, like a fresh client)."""
200
+
201
+ endpoint_kind = "clip_http"
202
+
203
+ def __init__(self, ep: Endpoint, timeout_s: float = DEFAULT_TIMEOUT_S, workflow: str | None = None):
204
+ if workflow:
205
+ raise ValueError("--workflow is for the comfyui backend; the clip HTTP contract takes none")
206
+ self.ep, self.timeout_s = ep, timeout_s
207
+
208
+ def _connection(self, timeout: float) -> http.client.HTTPConnection:
209
+ if self.ep.scheme == "https":
210
+ return http.client.HTTPSConnection(self.ep.host, self.ep.port, timeout=timeout)
211
+ return http.client.HTTPConnection(self.ep.host, self.ep.port, timeout=timeout)
212
+
213
+ def _headers(self, extra: dict | None = None) -> dict:
214
+ h = {"Accept": "application/json, video/*", **(extra or {})}
215
+ if self.ep.api_key:
216
+ h["Authorization"] = f"Bearer {self.ep.api_key}"
217
+ return h
218
+
219
+ def health(self) -> tuple[int | None, str]:
220
+ conn = self._connection(10.0)
221
+ try:
222
+ conn.request("GET", self.ep.prefix + HEALTH_PATH, headers=self._headers())
223
+ resp = conn.getresponse()
224
+ body = resp.read(MAX_ERROR_BODY).decode(errors="replace")
225
+ return resp.status, scrub(body.strip(), self.ep)[:200]
226
+ except (OSError, http.client.HTTPException) as e:
227
+ return None, scrub(f"{type(e).__name__}: {e}", self.ep)
228
+ finally:
229
+ conn.close()
230
+
231
+ def generate(self, request: ClipRequest) -> ClipResult:
232
+ res = ClipResult(id=request.id)
233
+ payload = json.dumps(request.body()).encode()
234
+ conn = self._connection(self.timeout_s)
235
+ announced = None
236
+ res.t_send_ns = time.perf_counter_ns()
237
+ try:
238
+ conn.connect()
239
+ res.t_connected_ns = time.perf_counter_ns()
240
+ conn.request("POST", self.ep.prefix + CLIPS_PATH, body=payload,
241
+ headers=self._headers({"Content-Type": "application/json"}))
242
+ resp = conn.getresponse()
243
+ res.status = resp.status
244
+ raw = resp.read()
245
+ ctype = (resp.getheader("Content-Type") or "").split(";")[0].strip().lower()
246
+ meta_header = resp.getheader("X-Clip-Meta")
247
+ if resp.status != 200:
248
+ res.error = f"HTTP {resp.status}: {raw[:MAX_ERROR_BODY].decode(errors='replace').strip()}"
249
+ elif ctype.startswith("video/") or ctype == "application/octet-stream":
250
+ res.video, res.via = raw, "body"
251
+ # The engine picks the subtype and it becomes a file extension: sanitised like "format".
252
+ res.format = _clip_format(ctype.split("/", 1)[1]) if ctype.startswith("video/") else "bin"
253
+ meta = json.loads(meta_header) if meta_header else {}
254
+ self._meta(res, meta)
255
+ announced = _announced(meta)
256
+ else:
257
+ announced = self._json_answer(res, json.loads(raw))
258
+ if res.video is not None and res.error is None:
259
+ res.t_done_ns = time.perf_counter_ns()
260
+ res.ok = len(res.video) > 0
261
+ if not res.ok:
262
+ res.error = "empty clip"
263
+ elif announced is not None: # after t_done: the check is not timed, and a mismatch fails the clip
264
+ res.error = _mismatch(res.video, announced, res.via)
265
+ except (OSError, http.client.HTTPException) as e:
266
+ res.error = f"{type(e).__name__}: {e}"
267
+ except (ValueError, binascii.Error) as e: # JSON, base64 or the meta header did not parse
268
+ res.error = f"bad answer: {type(e).__name__}: {e}"
269
+ finally:
270
+ conn.close()
271
+ res.t_end_ns = time.perf_counter_ns()
272
+ if res.error:
273
+ res.ok, res.video = False, None
274
+ res.error = scrub(res.error, self.ep)[:MAX_ERROR_BODY]
275
+ return res
276
+
277
+ def _json_answer(self, res: ClipResult, doc) -> tuple | None:
278
+ """Take the clip from a JSON answer; return the announced (sha256, bytes) to check once it is in hand."""
279
+ if not isinstance(doc, dict):
280
+ raise ValueError("the answer is not a JSON object")
281
+ announced = _announced(doc)
282
+ if isinstance(doc.get("video_b64"), str):
283
+ res.video, res.via = base64.b64decode(doc["video_b64"], validate=True), "video_b64"
284
+ elif isinstance(doc.get("video_path"), str):
285
+ if not self.ep.is_local():
286
+ res.error = "video_path from a server on another host cannot be read; send video_b64"
287
+ return None
288
+ if announced is None or None in announced:
289
+ # Without them the client cannot tell a finished file from one the server is still writing.
290
+ res.error = "a video_path answer must carry video_sha256 and video_bytes of the finished file"
291
+ return None
292
+ try:
293
+ res.video, res.via = Path(doc["video_path"]).read_bytes(), "video_path"
294
+ except OSError as e:
295
+ res.error = f"video_path not readable: {type(e).__name__}"
296
+ return None
297
+ else:
298
+ res.error = "the answer has neither video_b64 nor video_path"
299
+ return None
300
+ res.format = _clip_format(doc.get("format"))
301
+ self._meta(res, doc)
302
+ return announced
303
+
304
+ @staticmethod
305
+ def _meta(res: ClipResult, doc: dict) -> None:
306
+ if not isinstance(doc, dict):
307
+ raise ValueError("X-Clip-Meta is not a JSON object")
308
+ if doc.get("format"):
309
+ res.format = _clip_format(doc["format"])
310
+ res.stage_ms = doc.get("stage_ms") if isinstance(doc.get("stage_ms"), dict) else None
311
+ res.effective = doc.get("effective") if isinstance(doc.get("effective"), dict) else None
312
+ cache = doc.get("cache")
313
+ if isinstance(cache, list):
314
+ names = [str(c) for c in cache]
315
+ res.cache = [c for c in names if c not in ALLOWED_CACHE]
316
+ res.cache_allowed = [c for c in names if c in ALLOWED_CACHE]
317
+ else:
318
+ res.cache = None
319
+
320
+ def describe(self) -> dict:
321
+ return {"endpoint_kind": self.endpoint_kind, "paths": [HEALTH_PATH, CLIPS_PATH]}
322
+
323
+ def close(self) -> None:
324
+ return None
325
+
326
+
327
+ # --------------------------------------------------------------------------- backend registry
328
+
329
+ BackendFactory = Callable[..., ClipBackend]
330
+ _BACKENDS: dict[str, BackendFactory] = {"clip_http": ClipHTTP}
331
+ # Backends that live in their own module; it registers itself when imported.
332
+ _LAZY = {"comfyui": "inferencekit.engines.clip.comfyui"}
333
+
334
+
335
+ def register_backend(kind: str, factory: BackendFactory) -> None:
336
+ """Make ``kind`` available to bench clip. ``factory(ep, timeout_s=..., workflow=...)`` returns a ClipBackend."""
337
+ _BACKENDS[kind] = factory
338
+
339
+
340
+ def make_backend(kind: str, ep: Endpoint, timeout_s: float = DEFAULT_TIMEOUT_S,
341
+ workflow: str | None = None) -> ClipBackend:
342
+ """The backend for an endpoint kind. Raises LookupError when it is not available in this build."""
343
+ if kind not in _BACKENDS and kind in _LAZY:
344
+ try:
345
+ importlib.import_module(_LAZY[kind])
346
+ except ImportError as e:
347
+ raise LookupError(f"endpoint kind {kind!r} needs {_LAZY[kind]}, which is not in this build ({e})") from None
348
+ if kind not in _BACKENDS:
349
+ raise LookupError(f"unknown endpoint kind {kind!r}; known: {sorted(set(_BACKENDS) | set(_LAZY))}")
350
+ return _BACKENDS[kind](ep, timeout_s=timeout_s, workflow=workflow)
351
+