jev-compatible-server 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.
@@ -0,0 +1,5 @@
1
+ """Jev-compatible decision serving."""
2
+
3
+ from .protocol import DecisionRequest, DecisionResponse
4
+
5
+ __all__ = ["DecisionRequest", "DecisionResponse"]
@@ -0,0 +1,144 @@
1
+ """FastAPI application exposing the Jev-compatible endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import AsyncIterator, Sequence
7
+ from contextlib import asynccontextmanager
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+
11
+ from .backends import LlamaBackend, load_decision_config
12
+ from .batching import DecisionBatcher
13
+ from .protocol import DecisionRequest, DecisionResponse
14
+ from .registry import ModelRegistry, RegistryRuntime, build_transformers_runtime
15
+ from .runtime import DecisionRuntime, RuntimeErrorBase, apply_question_type_support
16
+
17
+
18
+ def _positive_batch_size(value: str) -> int:
19
+ try:
20
+ parsed = int(value)
21
+ except ValueError as exc:
22
+ raise ValueError("must be a positive integer") from exc
23
+ if parsed <= 0:
24
+ raise ValueError("must be a positive integer")
25
+ return parsed
26
+
27
+
28
+ def _model_batch_size_override(explicit: int | None = None) -> int | None:
29
+ if explicit is not None:
30
+ if isinstance(explicit, bool) or explicit <= 0:
31
+ raise RuntimeErrorBase("model batch size override must be a positive integer")
32
+ return explicit
33
+ configured = os.environ.get("DECISION_MODEL_BATCH_SIZE")
34
+ if configured is None:
35
+ return None
36
+ try:
37
+ return _positive_batch_size(configured)
38
+ except ValueError as exc:
39
+ raise RuntimeErrorBase(
40
+ "DECISION_MODEL_BATCH_SIZE must be a positive integer"
41
+ ) from exc
42
+
43
+
44
+ def build_runtime(*, model_batch_size: int | None = None) -> DecisionRuntime:
45
+ model_batch_size = _model_batch_size_override(model_batch_size)
46
+ registry_path = os.environ.get("DECISION_REGISTRY")
47
+ if registry_path:
48
+ return RegistryRuntime(
49
+ ModelRegistry.from_file(registry_path),
50
+ model_batch_size=model_batch_size,
51
+ )
52
+ explicit_model = os.environ.get("DECISION_MODEL_ID") or os.environ.get(
53
+ "DECISION_MODEL_PATH"
54
+ )
55
+ if not explicit_model and "DECISION_BACKEND" not in os.environ:
56
+ return RegistryRuntime(
57
+ ModelRegistry.from_builtin(),
58
+ model_batch_size=model_batch_size,
59
+ )
60
+ backend = os.environ.get("DECISION_BACKEND", "transformers").lower()
61
+ config_path = os.environ.get("DECISION_CONFIG")
62
+ config = load_decision_config(config_path) if config_path else {}
63
+ if backend == "llama":
64
+ model_path = os.environ.get("DECISION_MODEL_PATH")
65
+ if not model_path:
66
+ raise RuntimeErrorBase("DECISION_MODEL_PATH is required for the llama backend")
67
+ return apply_question_type_support(
68
+ LlamaBackend(model_path, config=config), config
69
+ )
70
+ if backend == "transformers":
71
+ model_id = os.environ.get("DECISION_MODEL_ID")
72
+ if not model_id:
73
+ raise RuntimeErrorBase("DECISION_MODEL_ID is required for the transformers backend")
74
+ return apply_question_type_support(
75
+ build_transformers_runtime(
76
+ model_id,
77
+ config,
78
+ batch_size_override=model_batch_size,
79
+ ),
80
+ config,
81
+ )
82
+ raise RuntimeErrorBase(f"unknown DECISION_BACKEND: {backend}")
83
+
84
+
85
+ def create_app(
86
+ runtime: DecisionRuntime | None = None,
87
+ *,
88
+ model_batch_size: int | None = None,
89
+ ) -> FastAPI:
90
+ selected_runtime = runtime or build_runtime(model_batch_size=model_batch_size)
91
+ batcher = DecisionBatcher(
92
+ selected_runtime,
93
+ max_batch_size=int(os.environ.get("DECISION_MAX_BATCH_SIZE", "16")),
94
+ wait_ms=int(os.environ.get("DECISION_BATCH_WAIT_MS", "5")),
95
+ )
96
+
97
+ @asynccontextmanager
98
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
99
+ await batcher.start()
100
+ yield
101
+ await batcher.close()
102
+
103
+ app = FastAPI(title="jev-compatible-server", lifespan=lifespan)
104
+
105
+ @app.get("/health")
106
+ async def health() -> dict[str, str]:
107
+ return {"status": "ok", "model": selected_runtime.model_name}
108
+
109
+ @app.post("/v1/systemone", response_model=DecisionResponse)
110
+ @app.post("/systemone", response_model=DecisionResponse)
111
+ async def system_one(request: DecisionRequest) -> DecisionResponse:
112
+ try:
113
+ return await batcher.submit(request)
114
+ except RuntimeErrorBase as exc:
115
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
116
+ except Exception as exc:
117
+ raise HTTPException(status_code=500, detail="decision inference failed") from exc
118
+
119
+ return app
120
+
121
+
122
+ app: FastAPI | None = None
123
+
124
+
125
+ def main(argv: Sequence[str] | None = None) -> None:
126
+ import argparse
127
+
128
+ import uvicorn
129
+
130
+ parser = argparse.ArgumentParser(description="Serve a Jev-compatible decision API")
131
+ parser.add_argument(
132
+ "--model-batch-size",
133
+ type=_positive_batch_size,
134
+ help=(
135
+ "override decision.batch_size for Transformers model forward passes; "
136
+ "defaults to the selected model recipe"
137
+ ),
138
+ )
139
+ args = parser.parse_args(argv)
140
+ uvicorn.run(
141
+ create_app(model_batch_size=args.model_batch_size),
142
+ host="0.0.0.0",
143
+ port=8000,
144
+ )
@@ -0,0 +1,320 @@
1
+ """Built-in llama.cpp and Transformers adapters.
2
+
3
+ Both adapters consume the same model metadata shape. A model can therefore be
4
+ added by publishing metadata and weights, without adding a service branch.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ from collections.abc import Sequence
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from .protocol import DecisionRequest, DecisionResponse, NoulQuestion, Usage
16
+ from .runtime import RuntimeErrorBase, TokenLogitRuntime
17
+
18
+
19
+ def load_decision_config(path: str | Path) -> dict[str, Any]:
20
+ value = json.loads(Path(path).read_text())
21
+ if not isinstance(value, dict):
22
+ raise RuntimeErrorBase("decision config must be a JSON object")
23
+ return value
24
+
25
+
26
+ class LlamaBackend(TokenLogitRuntime):
27
+ """llama-cpp-python adapter for GGUF token-logit decision models."""
28
+
29
+ def __init__(
30
+ self,
31
+ model_path: str,
32
+ *,
33
+ config: dict[str, Any] | None = None,
34
+ n_ctx: int = 4096,
35
+ n_gpu_layers: int = -1,
36
+ ):
37
+ try:
38
+ from llama_cpp import Llama # type: ignore[import-not-found]
39
+ except ImportError as exc: # pragma: no cover - optional dependency
40
+ raise RuntimeErrorBase(
41
+ "LlamaBackend requires the 'llama' optional dependency"
42
+ ) from exc
43
+ self._llama: Any = Llama(
44
+ model_path=model_path,
45
+ n_ctx=n_ctx,
46
+ n_gpu_layers=n_gpu_layers,
47
+ logits_all=True,
48
+ verbose=False,
49
+ )
50
+ metadata = getattr(self._llama, "metadata", {})
51
+ if callable(metadata):
52
+ metadata = metadata()
53
+ merged_config = dict(metadata) if isinstance(metadata, dict) else {}
54
+ merged_config.update(config or {})
55
+ super().__init__(model_name=str(merged_config.get("model", model_path)), config=merged_config)
56
+
57
+ def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
58
+ results: list[DecisionResponse] = []
59
+ for request in requests:
60
+ answers: dict[str, Any] = {}
61
+ input_tokens = 0
62
+ for name, question in request.questions.items():
63
+ prompt = self._prompt(request, name, question)
64
+ encoded = self._llama.tokenize(prompt.encode("utf-8"), add_bos=True)
65
+ self._llama.reset()
66
+ self._llama.eval(encoded)
67
+ input_tokens += len(encoded)
68
+ row = self._llama.scores[-1]
69
+ logits = {self._token_id(label): float(row[self._token_id(label)]) for label in self._labels(question)}
70
+ answers[name] = self._answer(question, logits)
71
+ results.append(DecisionResponse(model=self.model_name, answers=answers, usage=Usage(input_tokens=input_tokens)))
72
+ return results
73
+
74
+ @staticmethod
75
+ def _labels(question: Any) -> list[str]:
76
+ if hasattr(question, "criteria") and isinstance(question.criteria, dict):
77
+ return list(question.criteria)
78
+ if hasattr(question, "criteria") and isinstance(question.criteria, list):
79
+ return [str(index) for index in range(len(question.criteria))]
80
+ return ["true", "false"]
81
+
82
+
83
+ class TransformersBackend(TokenLogitRuntime):
84
+ """Transformers adapter for causal token-logit decision models.
85
+
86
+ Scalar/pointer models can use the same service by providing a future
87
+ readout adapter; this built-in path intentionally handles causal logits.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ model_id: str,
93
+ *,
94
+ config: dict[str, Any] | None = None,
95
+ device: str = "auto",
96
+ ):
97
+ try:
98
+ import torch
99
+ from transformers import AutoModelForCausalLM, AutoTokenizer
100
+ except ImportError as exc: # pragma: no cover - optional dependency
101
+ raise RuntimeErrorBase(
102
+ "TransformersBackend requires the 'transformers' optional dependency"
103
+ ) from exc
104
+ super().__init__(model_name=str((config or {}).get("model", model_id)), config=config)
105
+ self._torch = torch
106
+ self._tokenizer = AutoTokenizer.from_pretrained(model_id)
107
+ self._model = AutoModelForCausalLM.from_pretrained(model_id)
108
+ model_metadata = self._model.config.to_dict()
109
+ merged_config = dict(model_metadata)
110
+ merged_config.update(config or {})
111
+ self.config = merged_config
112
+ if device != "auto":
113
+ self._model.to(device)
114
+ elif torch.cuda.is_available():
115
+ self._model.to("cuda")
116
+ self._model.eval()
117
+
118
+ def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
119
+ prompts: list[tuple[int, str, Any]] = []
120
+ for request_index, request in enumerate(requests):
121
+ for name, question in request.questions.items():
122
+ prompts.append((request_index, name, question))
123
+ encoded = self._tokenizer(
124
+ [self._prompt(requests[index], name, question) for index, name, question in prompts],
125
+ return_tensors="pt",
126
+ padding=True,
127
+ truncation=True,
128
+ )
129
+ device = next(self._model.parameters()).device
130
+ encoded = {key: value.to(device) for key, value in encoded.items()}
131
+ with self._torch.inference_mode():
132
+ output = self._model(**encoded)
133
+ logits = output.logits[:, -1, :]
134
+ answers: list[dict[str, Any]] = [{} for _ in requests]
135
+ for row, (request_index, name, question) in enumerate(prompts):
136
+ labels = LlamaBackend._labels(question)
137
+ values = {self._token_id(label): float(logits[row, self._token_id(label)].item()) for label in labels}
138
+ answers[request_index][name] = self._answer(question, values)
139
+ return [
140
+ DecisionResponse(model=self.model_name, answers=answer, usage=Usage())
141
+ for answer in answers
142
+ ]
143
+
144
+
145
+ class PointerTransformersBackend(TokenLogitRuntime):
146
+ """Owned implementation of the Qwen + LoRA + pointer-head pattern.
147
+
148
+ The model repository is only a source of weights. Packing delimiters,
149
+ masking, LoRA location, and pointer tensors come from service-owned
150
+ registry metadata, so this does not import a model author's serving code.
151
+ """
152
+
153
+ def __init__(self, model_id: str, *, config: dict[str, Any] | None = None, device: str = "auto"):
154
+ try:
155
+ import torch
156
+ from transformers import AutoModelForCausalLM, AutoTokenizer
157
+ except ImportError as exc: # pragma: no cover - optional dependency
158
+ raise RuntimeErrorBase("PointerTransformersBackend requires transformers and torch") from exc
159
+ super().__init__(model_name=str((config or {}).get("model", model_id)), config=config)
160
+ self._torch = torch
161
+ backbone = self.config.get("decision.backbone") or self.config.get("backbone") or model_id
162
+ self._tokenizer = AutoTokenizer.from_pretrained(backbone)
163
+ load_kwargs: dict[str, Any] = {}
164
+ if torch.cuda.is_available():
165
+ load_kwargs["torch_dtype"] = torch.bfloat16
166
+ # Pointer models provide a custom 4-D block-causal mask; eager attention
167
+ # preserves that mask contract across Transformers releases.
168
+ load_kwargs["attn_implementation"] = "eager"
169
+ full_model = AutoModelForCausalLM.from_pretrained(backbone, **load_kwargs)
170
+ self._model = getattr(full_model, "model", full_model)
171
+ adapter = self.config.get("decision.adapter") or self.config.get("adapter")
172
+ if adapter == "auto":
173
+ adapter = model_id
174
+ if adapter:
175
+ try:
176
+ from peft import PeftModel
177
+ except ImportError as exc: # pragma: no cover - optional dependency
178
+ raise RuntimeErrorBase("pointer-head LoRA models require peft") from exc
179
+ self._model = PeftModel.from_pretrained(self._model, adapter)
180
+ target = "cuda" if device == "auto" and torch.cuda.is_available() else device
181
+ if target != "auto":
182
+ self._model.to(target)
183
+ self._model.eval()
184
+ self._device = next(self._model.parameters()).device
185
+ self._load_pointer_head()
186
+
187
+ def _load_pointer_head(self) -> None:
188
+ import torch
189
+
190
+ head_path = self.config.get("decision.head_path") or self.config.get("head_path")
191
+ if not isinstance(head_path, str):
192
+ raise RuntimeErrorBase("pointer_head requires decision.head_path")
193
+ if not __import__("os").path.exists(head_path):
194
+ repo = self.config.get("decision.head_repo") or self.config.get("head_repo") or self.config.get("model")
195
+ if not isinstance(repo, str):
196
+ raise RuntimeErrorBase("relative pointer head path requires decision.head_repo")
197
+ try:
198
+ from huggingface_hub import hf_hub_download
199
+
200
+ head_path = hf_hub_download(repo_id=repo, filename=head_path)
201
+ except Exception as exc: # pragma: no cover - network/model dependent
202
+ raise RuntimeErrorBase(f"could not resolve pointer head {head_path!r} from {repo!r}") from exc
203
+ payload = torch.load(head_path, map_location="cpu", weights_only=True)
204
+ state = payload.get("state_dict", payload) if isinstance(payload, dict) else payload
205
+ if isinstance(state, dict) and isinstance(state.get("head"), dict):
206
+ state = state["head"]
207
+ if not isinstance(state, dict):
208
+ raise RuntimeErrorBase("pointer head checkpoint must contain a state dict")
209
+ q_weight = self._find_tensor(state, ("q.weight", "query.weight"))
210
+ k_weight = self._find_tensor(state, ("k.weight", "key.weight"))
211
+ q_bias = self._find_tensor(state, ("q.bias", "query.bias"), required=False)
212
+ k_bias = self._find_tensor(state, ("k.bias", "key.bias"), required=False)
213
+ if q_weight is None or k_weight is None:
214
+ raise RuntimeErrorBase("pointer head checkpoint needs q/query and k/key weights")
215
+ self._q_weight = q_weight.to(self._device)
216
+ self._k_weight = k_weight.to(self._device)
217
+ self._q_bias = q_bias.to(self._device) if q_bias is not None else None
218
+ self._k_bias = k_bias.to(self._device) if k_bias is not None else None
219
+
220
+ @staticmethod
221
+ def _find_tensor(
222
+ state: dict[str, Any],
223
+ suffixes: tuple[str, ...],
224
+ *,
225
+ required: bool = True,
226
+ ) -> Any | None:
227
+ for key, value in state.items():
228
+ if isinstance(key, str) and any(key.endswith(suffix) for suffix in suffixes):
229
+ return value
230
+ if required:
231
+ return None
232
+ return None
233
+
234
+ def _packing(self) -> dict[str, Any]:
235
+ packing = self.config.get("decision.packing", self.config.get("packing", {}))
236
+ if not isinstance(packing, dict):
237
+ raise RuntimeErrorBase("decision.packing must be an object")
238
+ required = ("state_token", "question_token", "option_start", "option_end", "decision_token")
239
+ missing = [key for key in required if not isinstance(packing.get(key), str)]
240
+ if missing:
241
+ raise RuntimeErrorBase(f"pointer_head packing metadata missing: {', '.join(missing)}")
242
+ return packing
243
+
244
+ def _encode(self, request: DecisionRequest, question: Any) -> dict[str, Any]:
245
+ packing = self._packing()
246
+ tok = self._tokenizer
247
+
248
+ def user(value: Any) -> list[int]:
249
+ text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
250
+ return cast(list[int], tok(text, add_special_tokens=False).input_ids)
251
+
252
+ def special(name: str) -> int:
253
+ token = packing[name]
254
+ token_id = tok.convert_tokens_to_ids(token)
255
+ if token_id is None or token_id < 0:
256
+ raise RuntimeErrorBase(f"packing token is not in tokenizer vocabulary: {token}")
257
+ return int(token_id)
258
+
259
+ state = [special("state_token"), *user(request.state)]
260
+ instruction = [special("question_token"), *user(question.instructions)]
261
+ if isinstance(question, NoulQuestion):
262
+ criteria: list[Any] = ["false", "true"]
263
+ elif isinstance(question.criteria, dict):
264
+ criteria = [f"{key}: {value}" if value else key for key, value in question.criteria.items()]
265
+ else:
266
+ criteria = list(question.criteria)
267
+ spans = [[special("option_start"), *user(value), special("option_end")] for value in criteria]
268
+ ids = state + instruction
269
+ seg = [0] * len(state) + [1] * len(instruction)
270
+ opt = [-1] * len(ids)
271
+ ends: list[int] = []
272
+ for option_index, span in enumerate(spans):
273
+ start = len(ids)
274
+ ids.extend(span)
275
+ seg.extend([1] * len(span))
276
+ opt.extend([option_index] * len(span))
277
+ ends.append(start + len(span) - 1)
278
+ decision_index = len(ids)
279
+ ids.append(special("decision_token")); seg.append(1); opt.append(-2)
280
+ return {"ids": ids, "seg": seg, "opt": opt, "ends": ends, "decision": decision_index}
281
+
282
+ def _hidden_batch(self, encodings: list[dict[str, Any]]) -> Any:
283
+ torch = self._torch
284
+ length = max(len(item["ids"]) for item in encodings)
285
+ pad_id = self._tokenizer.pad_token_id or 0
286
+ ids = torch.full((len(encodings), length), pad_id, dtype=torch.long, device=self._device)
287
+ mask = torch.full((len(encodings), 1, length, length), torch.finfo(torch.float32).min, device=self._device)
288
+ positions = torch.zeros((len(encodings), length), dtype=torch.long, device=self._device)
289
+ for row, item in enumerate(encodings):
290
+ size = len(item["ids"]); ids[row, :size] = torch.tensor(item["ids"], device=self._device)
291
+ positions[row, :size] = torch.arange(size, device=self._device)
292
+ seg = torch.tensor(item["seg"], device=self._device)
293
+ opt = torch.tensor(item["opt"], device=self._device)
294
+ causal = torch.tril(torch.ones((size, size), dtype=torch.bool, device=self._device))
295
+ allowed = causal & ((seg[None, :] == 0) | (seg[None, :] == seg[:, None]))
296
+ option_keys = opt[None, :] >= 0
297
+ decide = opt[:, None] == -2
298
+ allowed &= (~option_keys | decide | (opt[None, :] == opt[:, None]))
299
+ mask[row, 0, :size, :size] = torch.where(allowed, 0.0, torch.finfo(torch.float32).min)
300
+ with torch.inference_mode():
301
+ return self._model(input_ids=ids, position_ids=positions, attention_mask=mask).last_hidden_state.float()
302
+
303
+ def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
304
+ flattened: list[tuple[int, str, Any, dict[str, Any]]] = []
305
+ for request_index, request in enumerate(requests):
306
+ for name, question in request.questions.items():
307
+ flattened.append((request_index, name, question, self._encode(request, question)))
308
+ hidden = self._hidden_batch([item[3] for item in flattened])
309
+ answers: list[dict[str, Any]] = [{} for _ in requests]
310
+ for row, (request_index, name, question, encoding) in enumerate(flattened):
311
+ query = hidden[row, encoding["decision"]]
312
+ options = hidden[row, encoding["ends"]]
313
+ q = self._q_weight @ query + (self._q_bias if self._q_bias is not None else 0)
314
+ k = options @ self._k_weight.T
315
+ scores = (k @ q) / math.sqrt(q.shape[-1])
316
+ labels = ["false", "true"] if isinstance(question, NoulQuestion) else self._labels_for_question(question)
317
+ answers[request_index][name] = self.answer_from_label_scores(
318
+ question, {label: float(value) for label, value in zip(labels, scores.tolist(), strict=True)}
319
+ )
320
+ return [DecisionResponse(model=self.model_name, answers=answer, usage=Usage()) for answer in answers]
@@ -0,0 +1,68 @@
1
+ """Small dynamic microbatcher for Jev-compatible requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+
8
+ from .protocol import DecisionRequest, DecisionResponse
9
+ from .runtime import DecisionRuntime
10
+
11
+
12
+ @dataclass
13
+ class _Pending:
14
+ request: DecisionRequest
15
+ future: asyncio.Future[DecisionResponse]
16
+
17
+
18
+ class DecisionBatcher:
19
+ """Collects concurrent requests for a bounded interval and runs one batch."""
20
+
21
+ def __init__(self, runtime: DecisionRuntime, *, max_batch_size: int = 16, wait_ms: int = 5):
22
+ self.runtime = runtime
23
+ self.max_batch_size = max_batch_size
24
+ self.wait_seconds = wait_ms / 1000
25
+ self._queue: asyncio.Queue[_Pending] = asyncio.Queue()
26
+ self._worker: asyncio.Task[None] | None = None
27
+
28
+ async def start(self) -> None:
29
+ if self._worker is None:
30
+ self._worker = asyncio.create_task(self._run())
31
+
32
+ async def close(self) -> None:
33
+ if self._worker is not None:
34
+ self._worker.cancel()
35
+ await asyncio.gather(self._worker, return_exceptions=True)
36
+ self._worker = None
37
+
38
+ async def submit(self, request: DecisionRequest) -> DecisionResponse:
39
+ if self._worker is None:
40
+ await self.start()
41
+ future: asyncio.Future[DecisionResponse] = asyncio.get_running_loop().create_future()
42
+ await self._queue.put(_Pending(request, future))
43
+ return await future
44
+
45
+ async def _run(self) -> None:
46
+ while True:
47
+ first = await self._queue.get()
48
+ batch = [first]
49
+ deadline = asyncio.get_running_loop().time() + self.wait_seconds
50
+ while len(batch) < self.max_batch_size:
51
+ timeout = deadline - asyncio.get_running_loop().time()
52
+ if timeout <= 0:
53
+ break
54
+ try:
55
+ batch.append(await asyncio.wait_for(self._queue.get(), timeout))
56
+ except TimeoutError:
57
+ break
58
+ try:
59
+ responses = await asyncio.to_thread(
60
+ self.runtime.decide_batch, [item.request for item in batch]
61
+ )
62
+ if len(responses) != len(batch):
63
+ raise RuntimeError("runtime returned the wrong batch length")
64
+ for item, response in zip(batch, responses, strict=True):
65
+ item.future.set_result(response)
66
+ except Exception as exc: # noqa: BLE001 - propagate backend failures to every future
67
+ for item in batch:
68
+ item.future.set_exception(exc)