taskiq-llm-queue 0.1.0a1__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 (65) hide show
  1. taskiq_llm_queue/__init__.py +138 -0
  2. taskiq_llm_queue/_logging.py +40 -0
  3. taskiq_llm_queue/_version.py +5 -0
  4. taskiq_llm_queue/broker/__init__.py +86 -0
  5. taskiq_llm_queue/broker/acker.py +239 -0
  6. taskiq_llm_queue/broker/batching.py +188 -0
  7. taskiq_llm_queue/broker/broker.py +937 -0
  8. taskiq_llm_queue/broker/delivery.py +50 -0
  9. taskiq_llm_queue/broker/errors.py +178 -0
  10. taskiq_llm_queue/broker/events.py +51 -0
  11. taskiq_llm_queue/broker/filters.py +32 -0
  12. taskiq_llm_queue/broker/heartbeat.py +157 -0
  13. taskiq_llm_queue/broker/queue.py +141 -0
  14. taskiq_llm_queue/broker/s3_offload.py +346 -0
  15. taskiq_llm_queue/broker/scheduler.py +113 -0
  16. taskiq_llm_queue/broker/serializer.py +32 -0
  17. taskiq_llm_queue/cli.py +488 -0
  18. taskiq_llm_queue/client.py +1409 -0
  19. taskiq_llm_queue/config.py +592 -0
  20. taskiq_llm_queue/context.py +62 -0
  21. taskiq_llm_queue/core.py +832 -0
  22. taskiq_llm_queue/envelope.py +136 -0
  23. taskiq_llm_queue/errors.py +71 -0
  24. taskiq_llm_queue/failover.py +286 -0
  25. taskiq_llm_queue/http_transport.py +220 -0
  26. taskiq_llm_queue/keys.py +87 -0
  27. taskiq_llm_queue/langchain/__init__.py +64 -0
  28. taskiq_llm_queue/langchain/_client.py +142 -0
  29. taskiq_llm_queue/langchain/bedrock.py +291 -0
  30. taskiq_llm_queue/langchain/openai.py +103 -0
  31. taskiq_llm_queue/lifecycle.py +512 -0
  32. taskiq_llm_queue/metrics/__init__.py +45 -0
  33. taskiq_llm_queue/metrics/emf.py +100 -0
  34. taskiq_llm_queue/metrics/prometheus.py +66 -0
  35. taskiq_llm_queue/metrics_names.py +19 -0
  36. taskiq_llm_queue/protocols.py +140 -0
  37. taskiq_llm_queue/providers/__init__.py +62 -0
  38. taskiq_llm_queue/providers/bedrock.py +188 -0
  39. taskiq_llm_queue/providers/classify.py +251 -0
  40. taskiq_llm_queue/providers/embeddings.py +87 -0
  41. taskiq_llm_queue/providers/hosts.py +34 -0
  42. taskiq_llm_queue/providers/openai_compat.py +142 -0
  43. taskiq_llm_queue/providers/registry.py +173 -0
  44. taskiq_llm_queue/py.typed +0 -0
  45. taskiq_llm_queue/reliability/__init__.py +59 -0
  46. taskiq_llm_queue/reliability/_lua.py +461 -0
  47. taskiq_llm_queue/reliability/breaker.py +86 -0
  48. taskiq_llm_queue/reliability/claims.py +226 -0
  49. taskiq_llm_queue/reliability/config_store.py +148 -0
  50. taskiq_llm_queue/reliability/fallback.py +111 -0
  51. taskiq_llm_queue/reliability/limiter.py +482 -0
  52. taskiq_llm_queue/reliability/local_limiter.py +153 -0
  53. taskiq_llm_queue/results/__init__.py +25 -0
  54. taskiq_llm_queue/results/backend.py +226 -0
  55. taskiq_llm_queue/results/dispatcher.py +366 -0
  56. taskiq_llm_queue/results/s3_backend.py +105 -0
  57. taskiq_llm_queue/routing.py +173 -0
  58. taskiq_llm_queue/routing_hooks.py +90 -0
  59. taskiq_llm_queue/tasks.py +453 -0
  60. taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
  61. taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
  62. taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
  63. taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
  64. taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  65. taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,1409 @@
1
+ """`LLMQueue`: the public client. Sends calls through SQS (or runs them `direct`) and works them.
2
+
3
+ One instance per process. `start(mode)` connects Valkey and SQS, starts the result dispatcher
4
+ (senders) and the in-process taskiq receivers (workers); `stop()` drains in the order of §7.4.
5
+ Calls build an envelope from `request_context`, route it, and await the reply (§6.1).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import base64
12
+ import contextlib
13
+ import math
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from collections.abc import Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence
18
+ from dataclasses import dataclass, field
19
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar
20
+
21
+ import orjson
22
+ from redis.asyncio import Redis
23
+ from redis.exceptions import RedisError
24
+ from taskiq.exceptions import SendTaskError
25
+
26
+ from taskiq_llm_queue import metrics_names as mn
27
+ from taskiq_llm_queue._logging import get_logger
28
+ from taskiq_llm_queue.broker.errors import ErrorFamily, SQSSendError
29
+ from taskiq_llm_queue.broker.events import BrokerHooks
30
+ from taskiq_llm_queue.config import Lane, create_redis, create_sync_redis
31
+ from taskiq_llm_queue.context import current_context
32
+ from taskiq_llm_queue.core import CoreDeps, InfraError, JobResult, execute_job
33
+ from taskiq_llm_queue.envelope import (
34
+ TASK_CALL,
35
+ TASK_EMBED,
36
+ TASK_JOB,
37
+ CallMeta,
38
+ Envelope,
39
+ LLMReply,
40
+ task_labels,
41
+ )
42
+ from taskiq_llm_queue.errors import (
43
+ ConfigError,
44
+ LLMQueueCancelled,
45
+ LLMQueueError,
46
+ LLMQueueTimeout,
47
+ ProviderError,
48
+ QueueUnavailable,
49
+ )
50
+ from taskiq_llm_queue.failover import FailoverSettings, FailoverState
51
+ from taskiq_llm_queue.keys import KeySpace
52
+ from taskiq_llm_queue.lifecycle import (
53
+ BrokerSet,
54
+ ClaimGroup,
55
+ MetricsHooks,
56
+ PodHeartbeat,
57
+ PodRegistry,
58
+ Workers,
59
+ breaker_settings,
60
+ build_brokers,
61
+ claim_settings,
62
+ limiter_settings,
63
+ queue_for_lane,
64
+ queue_name,
65
+ )
66
+ from taskiq_llm_queue.metrics import MetricsSink, NullMetrics
67
+ from taskiq_llm_queue.protocols import ClaimDecision, ClaimState
68
+ from taskiq_llm_queue.providers.embeddings import (
69
+ batches,
70
+ embedding_body,
71
+ max_batch_size,
72
+ parse_embeddings,
73
+ )
74
+ from taskiq_llm_queue.providers.registry import ProviderRegistry
75
+ from taskiq_llm_queue.reliability.breaker import CircuitBreaker
76
+ from taskiq_llm_queue.reliability.claims import RedisClaimStore
77
+ from taskiq_llm_queue.reliability.config_store import LiveConfigStore, fallback_store
78
+ from taskiq_llm_queue.reliability.fallback import FallbackConfig
79
+ from taskiq_llm_queue.reliability.limiter import AdaptiveLimiter, SyncAdaptiveLimiter
80
+ from taskiq_llm_queue.reliability.local_limiter import LocalLimiter
81
+ from taskiq_llm_queue.results.backend import LLMQResultBackend
82
+ from taskiq_llm_queue.results.dispatcher import ResultDispatcher, ResultsUnavailable
83
+ from taskiq_llm_queue.routing import CanaryRouter, RoutingConfig, routing_store
84
+ from taskiq_llm_queue.routing_hooks import CallPath, QueueHooks, safe_hook
85
+ from taskiq_llm_queue.tasks import JOB_VERSION, JobFunction, WorkerResultBackend, WorkerRuntime
86
+
87
+ if TYPE_CHECKING:
88
+ import redis
89
+ from aiobotocore.session import AioSession
90
+
91
+ from taskiq_llm_queue.config import QueueConfig
92
+ from taskiq_llm_queue.envelope import FallbackPolicy, Op
93
+ from taskiq_llm_queue.protocols import Lease, Limiter
94
+ from taskiq_llm_queue.providers.registry import ProviderFactory
95
+ from taskiq_llm_queue.routing_hooks import FailoverPolicy, PathRouter
96
+
97
+ logger = get_logger(__name__)
98
+
99
+ Mode = Literal["send", "work", "both"]
100
+ # HTTP status the OpenAI transport reports for an error reply, by `ErrorKind` value.
101
+ _STATUS_BY_KIND: Mapping[str, int] = {"throttle": 429, "retryable": 503, "fatal": 400}
102
+ _T = TypeVar("_T") # PEP 695 needs 3.12; the package supports 3.11
103
+ # Share of the remaining call budget an SQS send may use, so a failed send leaves the rest
104
+ # for the `direct` failover run (capped further by `sqs_send_timeout_secs`).
105
+ _SEND_BUDGET_SHARE = 0.5
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class ConverseResult:
110
+ """`converse` result: the exact Bedrock Converse dict and how the call was served."""
111
+
112
+ response: dict[str, Any]
113
+ meta: CallMeta
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class ChatResult:
118
+ """`chat` result: the OpenAI-compatible chat completion body and how it was served."""
119
+
120
+ response: dict[str, Any]
121
+ meta: CallMeta
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class InvokeResult:
126
+ """`invoke_model` result: the raw model body, its content type and how it was served.
127
+
128
+ `response` gives the InvokeModel-style `{"body": bytes, "contentType": str}`; `json()`
129
+ parses a JSON body.
130
+ """
131
+
132
+ body: bytes
133
+ content_type: str
134
+ meta: CallMeta
135
+
136
+ @property
137
+ def response(self) -> dict[str, Any]:
138
+ """`{"body": bytes, "contentType": str}`, as boto3's InvokeModel reply has them."""
139
+ return {"body": self.body, "contentType": self.content_type}
140
+
141
+ def json(self) -> Any:
142
+ """The body parsed as JSON (embedding and most text models reply in JSON)."""
143
+ return orjson.loads(self.body)
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class EmbedResult:
148
+ """`embed` result: one vector per input text, in input order, and how they were served.
149
+
150
+ It also behaves as the list of vectors (`len`, iteration, indexing). With several provider
151
+ batches, `meta` merges them: first batch's model/target, summed attempts, slowest waits.
152
+ """
153
+
154
+ vectors: list[list[float]]
155
+ meta: CallMeta
156
+
157
+ def __len__(self) -> int:
158
+ return len(self.vectors)
159
+
160
+ def __iter__(self) -> Iterator[list[float]]:
161
+ return iter(self.vectors)
162
+
163
+ def __getitem__(self, index: int) -> list[float]:
164
+ return self.vectors[index]
165
+
166
+
167
+ @dataclass(slots=True)
168
+ class _Started:
169
+ """Everything `start()` creates; dropped as a whole by `stop()`."""
170
+
171
+ mode: Mode
172
+ redis: Redis
173
+ owns_redis: bool
174
+ results: LLMQResultBackend
175
+ limiter: AdaptiveLimiter
176
+ claims: RedisClaimStore
177
+ breaker: CircuitBreaker
178
+ fallback: LiveConfigStore[FallbackConfig]
179
+ dispatcher: ResultDispatcher
180
+ brokers: BrokerSet
181
+ runtime: WorkerRuntime
182
+ workers: Workers | None
183
+ pods: PodRegistry | None = None
184
+ tasks: list[asyncio.Task[Any]] = field(default_factory=list)
185
+
186
+
187
+ class LLMQueue:
188
+ """LLM request client with its own durable queue; also the in-process worker (README).
189
+
190
+ `router` picks `sqs` or `direct` per call; without one, `routing_enabled` builds a
191
+ `CanaryRouter` on the live routing config, else `sqs` when the model has queues. `failover`
192
+ defaults to a `FailoverState` from the config. `hooks` defaults to `MetricsHooks(metrics)`
193
+ (if it is also a `BrokerHooks`, the brokers report to it too). `provider_factories`,
194
+ `redis` and `aws_session` are injection points for tests and custom wiring.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ config: QueueConfig,
200
+ *,
201
+ pod_id: str | None = None,
202
+ router: PathRouter | None = None,
203
+ failover: FailoverPolicy | None = None,
204
+ hooks: QueueHooks | None = None,
205
+ metrics: MetricsSink | None = None,
206
+ provider_factories: Mapping[str, ProviderFactory] | None = None,
207
+ redis: Redis | None = None,
208
+ aws_session: AioSession | None = None,
209
+ ) -> None:
210
+ """Validate providers (boot-time `ConfigError`); no I/O until `start()`."""
211
+ self.config = config
212
+ self.pod_id = pod_id or f"pod-{uuid.uuid4().hex[:8]}"
213
+ self.metrics: MetricsSink = metrics or NullMetrics()
214
+ self.router = router
215
+ self._own_router: CanaryRouter | None = None
216
+ self.failover: FailoverPolicy = failover or FailoverState(
217
+ FailoverSettings(
218
+ sqs_failover_secs=config.sqs_failover_secs,
219
+ local_cap_fallback=config.local_limit_cap,
220
+ ),
221
+ probe=self._probe_redis,
222
+ metrics=self.metrics,
223
+ )
224
+ self.hooks = hooks or MetricsHooks(self.metrics)
225
+ self.keys = KeySpace(config.key_prefix)
226
+ self._factories = dict(provider_factories or {})
227
+ self.registry = ProviderRegistry(config, factories=self._factories)
228
+ self._injected_redis = redis
229
+ self._injected_session = aws_session
230
+ self.aws_session: AioSession | None = aws_session
231
+ self._state: _Started | None = None
232
+ self._direct_tasks: dict[str, asyncio.Task[Any]] = {}
233
+ self._embed_groups: dict[str, list[str]] = {} # embed request id -> batch request ids
234
+ # DS17 claims held as `direct:<pod>` (request id -> holders) and every in-flight lease
235
+ # of this pod (worker, direct and sync paths): both kept alive by the pod heartbeat.
236
+ self._direct_claims: dict[str, int] = {}
237
+ self._leases: set[Lease] = set()
238
+ # Cancel flags that could not be set while Valkey was down; set once a probe succeeds.
239
+ self._pending_cancels: set[str] = set()
240
+ self._sent_at = -math.inf # monotonic time the last SQS send completed (any queue)
241
+ self._local_limiter = _LocalLimiters(self.failover, config)
242
+ self._jobs: dict[str, JobFunction] = {}
243
+ self._sync: _SyncRunner | None = None
244
+ self._sync_lock = threading.Lock()
245
+ self.sync_fallback_count = 0
246
+
247
+ # ------------------------------------------------------------------ lifecycle
248
+
249
+ @property
250
+ def started(self) -> bool:
251
+ """True between `start()` and `stop()`."""
252
+ return self._state is not None
253
+
254
+ async def __aenter__(self) -> LLMQueue:
255
+ await self.start()
256
+ return self
257
+
258
+ async def __aexit__(self, *exc: object) -> None:
259
+ await self.stop()
260
+
261
+ async def _probe_redis(self) -> object:
262
+ """Valkey health probe for the default `FailoverState`."""
263
+ state = self._state
264
+ if state is None:
265
+ raise QueueUnavailable("not started")
266
+ pong = await state.redis.ping()
267
+ pending, self._pending_cancels = self._pending_cancels, set()
268
+ for request_id in pending: # queued copies of calls served direct must not run again
269
+ try:
270
+ await state.claims.cancel(request_id)
271
+ except Exception:
272
+ self._pending_cancels.add(request_id)
273
+ return pong
274
+
275
+ def _redis_ok(self) -> None:
276
+ """Tell the failover policy Valkey answered (ends Valkey-down mode early)."""
277
+ report = getattr(self.failover, "report_redis_ok", None)
278
+ if callable(report):
279
+ safe_hook(report)
280
+
281
+ async def _watch_limits(self, limiter: AdaptiveLimiter) -> None:
282
+ """Feed learned limits to the failover local cap and the `llmq_limit` gauge."""
283
+ note = getattr(self.failover, "note_limit", None)
284
+ while True:
285
+ await asyncio.sleep(self.config.cfg_reload_secs)
286
+ for (model, target), limit in list(limiter.last_limits.items()):
287
+ if callable(note):
288
+ safe_hook(note, model, target, limit)
289
+ labels = {"model": model, "target": target}
290
+ safe_hook(self.metrics.gauge, mn.LIMIT, float(limit), labels)
291
+
292
+ async def start(self, mode: Mode = "both") -> None:
293
+ """Connect and start: `send` (dispatcher), `work` (receivers) or `both`; idempotent."""
294
+ if self._state is not None:
295
+ return
296
+ if mode not in ("send", "work", "both"):
297
+ raise ConfigError(f"mode must be send, work or both, got {mode!r}")
298
+ config = self.config
299
+ owns = self._injected_redis is None
300
+ client = self._injected_redis or create_redis(config, client_name=self._redis_name())
301
+ # One AWS session (credential chain, IRSA refresh) for every provider of this pod.
302
+ self.aws_session = self._injected_session or _new_aws_session()
303
+ self.registry.aws_session = self.aws_session
304
+ results = LLMQResultBackend.from_config(config, redis=client)
305
+ limiter = AdaptiveLimiter(client, self.keys, limiter_settings(config))
306
+ claims = RedisClaimStore(client, self.keys, claim_settings(config))
307
+ breaker = CircuitBreaker(client, self.keys, breaker_settings(config))
308
+ default_chains = FallbackConfig.model_validate(config.fallback_default)
309
+ fallback = fallback_store(client, self.keys, default_chains)
310
+ dispatcher = ResultDispatcher(
311
+ results,
312
+ redis=client,
313
+ channel=self.keys.done_channel(self.pod_id),
314
+ intervals_ms=config.result_dispatch_ms,
315
+ # A send-only pod's only steady Valkey traffic: its errors must open Valkey-down
316
+ # mode, or calls keep going to SQS that no worker can claim (S21).
317
+ on_error=self._report_redis,
318
+ on_recovered=self._redis_ok,
319
+ abandon_after_secs=config.valkey_wait_failover_secs,
320
+ )
321
+ deps = CoreDeps(
322
+ config=config,
323
+ keys=self.keys,
324
+ providers=self.registry.for_model,
325
+ limiter=limiter,
326
+ claims=claims,
327
+ breaker=breaker,
328
+ results=results,
329
+ redis=client,
330
+ fallback=lambda: fallback.current,
331
+ worker=self.pod_id,
332
+ hooks=self.hooks,
333
+ leases=self._leases,
334
+ )
335
+ runtime = WorkerRuntime(
336
+ deps,
337
+ jobs=self._jobs,
338
+ defer_secs=round(2 * claims.settings.heartbeat_interval_secs),
339
+ failover=self.failover,
340
+ )
341
+ broker_hooks = self.hooks if isinstance(self.hooks, BrokerHooks) else None
342
+ brokers = build_brokers(
343
+ config,
344
+ runtime,
345
+ limiter,
346
+ WorkerResultBackend(results),
347
+ failover=self.failover,
348
+ hooks=broker_hooks,
349
+ session=self.aws_session,
350
+ )
351
+ started: list[Any] = []
352
+ try:
353
+ for broker in brokers.all():
354
+ await broker.startup()
355
+ started.append(broker)
356
+ await fallback.load()
357
+ except BaseException:
358
+ for broker in started:
359
+ with contextlib.suppress(Exception):
360
+ await broker.shutdown()
361
+ if owns:
362
+ await client.aclose()
363
+ raise
364
+ workers = Workers(brokers, runtime) if mode in ("work", "both") else None
365
+ interval = claims.settings.heartbeat_interval_secs
366
+ pods = PodRegistry(client, self.keys.pods, self.pod_id, ttl_secs=3 * interval)
367
+ state = _Started(
368
+ mode,
369
+ client,
370
+ owns,
371
+ results,
372
+ limiter,
373
+ claims,
374
+ breaker,
375
+ fallback,
376
+ dispatcher,
377
+ brokers,
378
+ runtime,
379
+ workers,
380
+ pods,
381
+ )
382
+ heartbeat = self._pod_heartbeat(state, interval)
383
+ state.tasks.append(
384
+ asyncio.create_task(fallback.run_reload(config.cfg_reload_secs), name="llmq-fallback")
385
+ )
386
+ state.tasks.append(asyncio.create_task(heartbeat.run(), name="llmq-pod-heartbeat"))
387
+ state.tasks.append(asyncio.create_task(self._watch_limits(limiter), name="llmq-limits"))
388
+ if self.router is None and config.routing_enabled:
389
+ default = RoutingConfig.model_validate(config.routing_default)
390
+ self._own_router = CanaryRouter(
391
+ routing_store(client, self.keys, default),
392
+ metrics=self.metrics,
393
+ reload_secs=config.cfg_reload_secs,
394
+ salt=config.id_hash_key,
395
+ )
396
+ await self._own_router.reload()
397
+ self._own_router.start()
398
+ if mode in ("send", "both"):
399
+ await dispatcher.start()
400
+ if workers is not None:
401
+ workers.start()
402
+ self._state = state
403
+ _set_default_queue(self)
404
+
405
+ def _pod_heartbeat(self, state: _Started, interval: float) -> PodHeartbeat:
406
+ """One heartbeat for every mode: worker claims, DS17 direct claims, all leases (DS11)."""
407
+ runtime = state.runtime
408
+
409
+ def worker_lost(lost: list[str]) -> None:
410
+ runtime.stop_running("claim_lost", lost)
411
+
412
+ def direct_lost(lost: list[str]) -> None:
413
+ logger.warning("direct_claims_lost", count=len(lost))
414
+
415
+ set_live = getattr(self.failover, "set_live_pods", None)
416
+ return PodHeartbeat(
417
+ claims=state.claims,
418
+ limiter=state.limiter,
419
+ leases=self._leases,
420
+ groups=[
421
+ ClaimGroup(self.pod_id, runtime.running_ids, worker_lost),
422
+ ClaimGroup(f"direct:{self.pod_id}", lambda: list(self._direct_claims), direct_lost),
423
+ ],
424
+ interval_secs=interval,
425
+ pods=state.pods,
426
+ on_live_pods=set_live if callable(set_live) else None,
427
+ )
428
+
429
+ async def stop(self, drain_secs: float | None = None) -> None:
430
+ """Ordered shutdown (§7.4): pause → drain running tasks → hand back leftovers → close.
431
+
432
+ `drain_secs` defaults to `config.drain_secs`. Waiting callers on this pod are failed
433
+ with `QueueUnavailable`. Also closes the sync runner (even if `start()` never ran, e.g.
434
+ when only `*_sync` calls were made). Idempotent.
435
+ """
436
+ state, self._state = self._state, None
437
+ if state is None:
438
+ await asyncio.to_thread(self.close_sync)
439
+ return
440
+ _set_default_queue(None, only=self)
441
+ drain = self.config.drain_secs if drain_secs is None else drain_secs
442
+ if state.workers is not None:
443
+ await state.workers.drain(drain)
444
+ else:
445
+ await asyncio.gather(*(b.pause_receiving() for b in state.brokers.all()))
446
+ router, self._own_router = self._own_router, None
447
+ if router is not None:
448
+ await router.aclose()
449
+ close_failover = getattr(self.failover, "aclose", None)
450
+ if callable(close_failover):
451
+ with contextlib.suppress(Exception):
452
+ await close_failover()
453
+ for task in state.tasks:
454
+ task.cancel()
455
+ await asyncio.gather(*state.tasks, return_exceptions=True)
456
+ await state.dispatcher.stop()
457
+ for broker in state.brokers.all():
458
+ try:
459
+ await broker.shutdown()
460
+ except Exception as exc:
461
+ logger.warning("broker_shutdown_failed", error_type=type(exc).__name__)
462
+ await self.registry.aclose()
463
+ if state.pods is not None:
464
+ with contextlib.suppress(Exception):
465
+ await state.pods.leave()
466
+ if state.owns_redis:
467
+ with contextlib.suppress(Exception):
468
+ await state.redis.aclose()
469
+ await state.redis.connection_pool.disconnect()
470
+ await asyncio.to_thread(self.close_sync)
471
+
472
+ def close_sync(self) -> None:
473
+ """Close the sync runner (thread, loop, providers, blocking Valkey client); idempotent.
474
+
475
+ For sync-only apps that never call `start()`/`stop()`; a later sync call starts a new one.
476
+ """
477
+ with self._sync_lock:
478
+ runner, self._sync = self._sync, None
479
+ if runner is not None:
480
+ runner.close()
481
+
482
+ async def pause_receiving(self) -> None:
483
+ """For `/drain`: stop taking new messages and release unstarted ones (§7.4 step 1)."""
484
+ if self._state is not None:
485
+ await asyncio.gather(*(b.pause_receiving() for b in self._state.brokers.all()))
486
+
487
+ def resume_receiving(self) -> None:
488
+ """Undo `pause_receiving`."""
489
+ if self._state is not None:
490
+ for broker in self._state.brokers.all():
491
+ broker.resume_receiving()
492
+
493
+ # ------------------------------------------------------------------ client API
494
+
495
+ async def converse(
496
+ self,
497
+ model: str,
498
+ request: dict[str, Any],
499
+ *,
500
+ lane: Lane | str | None = None,
501
+ fallback: FallbackPolicy | None = None,
502
+ timeout_secs: float | None = None,
503
+ deadline_at: float | None = None,
504
+ request_id: str | None = None,
505
+ ) -> ConverseResult:
506
+ """Bedrock Converse through the queue; `response` is the exact Converse dict.
507
+
508
+ `timeout_secs` (relative) defaults to the lane timeout; `deadline_at` (epoch seconds)
509
+ caps it. Raises `LLMQueueTimeout`, `LLMQueueCancelled` or `ProviderError`.
510
+ """
511
+ reply = await self._call(
512
+ "converse", model, request, lane, fallback, timeout_secs, deadline_at, request_id
513
+ )
514
+ return ConverseResult(reply.response or {}, reply.meta)
515
+
516
+ async def invoke_model(
517
+ self,
518
+ model: str,
519
+ body: Mapping[str, Any] | list[Any] | str | bytes,
520
+ *,
521
+ content_type: str = "application/json",
522
+ accept: str = "application/json",
523
+ lane: Lane | str | None = None,
524
+ fallback: FallbackPolicy | None = None,
525
+ timeout_secs: float | None = None,
526
+ deadline_at: float | None = None,
527
+ request_id: str | None = None,
528
+ ) -> InvokeResult:
529
+ """Bedrock InvokeModel with the model-native `body` (JSON value, or UTF-8 text/bytes).
530
+
531
+ Returns the raw reply body (`InvokeResult.body`, `.json()`) and how it was served.
532
+ """
533
+ params = {"body": _invoke_body(body), "contentType": content_type, "accept": accept}
534
+ reply = await self._call(
535
+ "invoke_model", model, params, lane, fallback, timeout_secs, deadline_at, request_id
536
+ )
537
+ return _invoke_result(reply)
538
+
539
+ async def chat(
540
+ self,
541
+ model: str,
542
+ request: dict[str, Any],
543
+ *,
544
+ lane: Lane | str | None = None,
545
+ fallback: FallbackPolicy | None = None,
546
+ timeout_secs: float | None = None,
547
+ deadline_at: float | None = None,
548
+ request_id: str | None = None,
549
+ ) -> ChatResult:
550
+ """OpenAI-compatible chat completion; `response` is the completion body as a dict."""
551
+ reply = await self._call(
552
+ "chat", model, request, lane, fallback, timeout_secs, deadline_at, request_id
553
+ )
554
+ return ChatResult(reply.response or {}, reply.meta)
555
+
556
+ async def embed(
557
+ self,
558
+ model: str,
559
+ texts: list[str],
560
+ *,
561
+ lane: Lane | str | None = None,
562
+ input_type: str | None = None,
563
+ timeout_secs: float | None = None,
564
+ deadline_at: float | None = None,
565
+ request_id: str | None = None,
566
+ ) -> EmbedResult:
567
+ """Embed `texts` as one task per provider batch (Titan 1, Cohere 96, OpenAI 2048).
568
+
569
+ Embeddings never switch model; vectors come back in input order. With several batches
570
+ each task gets `<request_id>-<n>`; `cancel(request_id)` cancels all of them.
571
+ """
572
+ if not texts:
573
+ return EmbedResult([], CallMeta())
574
+ plan = self._embed_plan(model, texts, input_type)
575
+ rid = request_id or _new_request_id()
576
+ ids = _batch_ids(rid, len(plan))
577
+ parent = rid if len(plan) > 1 else None
578
+ self._embed_groups[rid] = ids
579
+ try:
580
+ replies = await asyncio.gather(
581
+ *(
582
+ self._call(
583
+ op,
584
+ model,
585
+ params,
586
+ lane,
587
+ "same_model",
588
+ timeout_secs,
589
+ deadline_at,
590
+ batch_id,
591
+ embed=True,
592
+ parent_request_id=parent,
593
+ )
594
+ for (op, params), batch_id in zip(plan, ids, strict=True)
595
+ )
596
+ )
597
+ finally:
598
+ self._embed_groups.pop(rid, None)
599
+ return _embed_result(plan, replies)
600
+
601
+ async def cancel(self, request_id: str) -> None:
602
+ """Cancel a call, or every batch of an `embed`; its caller gets `LLMQueueCancelled`.
603
+
604
+ Sets the shared cancel flag (DS14) and stops a local `direct` run.
605
+ """
606
+ for rid in self._embed_groups.get(request_id) or [request_id]:
607
+ task = self._direct_tasks.get(rid)
608
+ if task is not None and not task.done():
609
+ task.cancel()
610
+ if self._state is not None:
611
+ await self._state.claims.cancel(rid)
612
+
613
+ # ------------------------------------------------------------------ jobs
614
+
615
+ def register_job(self, name: str, function: JobFunction) -> None:
616
+ """Allow-list an app job for the `taskiq_llm_queue.job` task on this pod."""
617
+ self._jobs[name] = function
618
+
619
+ async def send_job(
620
+ self,
621
+ name: str,
622
+ payload: dict[str, Any],
623
+ *,
624
+ job_id: str | None = None,
625
+ timeout_secs: float | None = None,
626
+ deadline_at: float | None = None,
627
+ ) -> str:
628
+ """Send one job to `config.job_queues[name]`; returns its job id (not awaited).
629
+
630
+ The job must finish within `timeout_secs` (default `state_ttl_secs`), capped by
631
+ `deadline_at` (epoch seconds).
632
+ """
633
+ state = self._require_started()
634
+ queue = self.config.job_queues.get(name)
635
+ if queue is None or state.brokers.jobs is None:
636
+ raise ConfigError(f"no job queue configured for {name!r}")
637
+ job_id = job_id or f"job-{uuid.uuid4().hex}"
638
+ now = time.time()
639
+ deadline = now + (timeout_secs if timeout_secs is not None else self.config.state_ttl_secs)
640
+ if deadline_at is not None:
641
+ deadline = min(deadline, deadline_at)
642
+ body: dict[str, Any] = {
643
+ "v": JOB_VERSION,
644
+ "request_id": job_id,
645
+ "job": name,
646
+ "payload": payload,
647
+ "deadline_at": max(deadline, now),
648
+ }
649
+ task = state.brokers.jobs.find_task(TASK_JOB)
650
+ assert task is not None
651
+ await task.kicker().with_task_id(job_id).with_labels(queue_name=queue_name(queue)).kiq(body)
652
+ return job_id
653
+
654
+ # ------------------------------------------------------------------ sync
655
+
656
+ def converse_sync(
657
+ self,
658
+ model: str,
659
+ request: dict[str, Any],
660
+ *,
661
+ lane: Lane | str | None = None,
662
+ fallback: FallbackPolicy | None = None,
663
+ timeout_secs: float | None = None,
664
+ deadline_at: float | None = None,
665
+ request_id: str | None = None,
666
+ ) -> ConverseResult:
667
+ """Blocking `converse`: runs `direct` under the shared limiter (sync fallback)."""
668
+ reply = self._run_sync(
669
+ model, "converse", request, lane, fallback, timeout_secs, deadline_at, request_id
670
+ )
671
+ return ConverseResult(reply.response or {}, reply.meta)
672
+
673
+ def chat_sync(
674
+ self,
675
+ model: str,
676
+ request: dict[str, Any],
677
+ *,
678
+ lane: Lane | str | None = None,
679
+ fallback: FallbackPolicy | None = None,
680
+ timeout_secs: float | None = None,
681
+ deadline_at: float | None = None,
682
+ request_id: str | None = None,
683
+ ) -> ChatResult:
684
+ """Blocking `chat`: runs `direct` under the shared limiter (sync fallback)."""
685
+ reply = self._run_sync(
686
+ model, "chat", request, lane, fallback, timeout_secs, deadline_at, request_id
687
+ )
688
+ return ChatResult(reply.response or {}, reply.meta)
689
+
690
+ def invoke_model_sync(
691
+ self,
692
+ model: str,
693
+ body: Mapping[str, Any] | list[Any] | str | bytes,
694
+ *,
695
+ content_type: str = "application/json",
696
+ accept: str = "application/json",
697
+ lane: Lane | str | None = None,
698
+ fallback: FallbackPolicy | None = None,
699
+ timeout_secs: float | None = None,
700
+ deadline_at: float | None = None,
701
+ request_id: str | None = None,
702
+ ) -> InvokeResult:
703
+ """Blocking `invoke_model`: runs `direct` under the shared limiter (sync fallback)."""
704
+ params = {"body": _invoke_body(body), "contentType": content_type, "accept": accept}
705
+ reply = self._run_sync(
706
+ model, "invoke_model", params, lane, fallback, timeout_secs, deadline_at, request_id
707
+ )
708
+ return _invoke_result(reply)
709
+
710
+ def embed_sync(
711
+ self,
712
+ model: str,
713
+ texts: list[str],
714
+ *,
715
+ lane: Lane | str | None = None,
716
+ input_type: str | None = None,
717
+ timeout_secs: float | None = None,
718
+ deadline_at: float | None = None,
719
+ request_id: str | None = None,
720
+ ) -> EmbedResult:
721
+ """Blocking `embed`: one `direct` call per provider batch under the shared limiter."""
722
+ if not texts:
723
+ return EmbedResult([], CallMeta())
724
+ plan = self._embed_plan(model, texts, input_type)
725
+ ids = _batch_ids(request_id or _new_request_id(), len(plan))
726
+ replies = [
727
+ self._run_sync(
728
+ model, op, params, lane, "same_model", timeout_secs, deadline_at, rid, embed=True
729
+ )
730
+ for (op, params), rid in zip(plan, ids, strict=True)
731
+ ]
732
+ return _embed_result(plan, replies)
733
+
734
+ # ------------------------------------------------------------------ internals
735
+
736
+ def _require_started(self) -> _Started:
737
+ if self._state is None:
738
+ raise QueueUnavailable("LLMQueue is not started; call start() first")
739
+ return self._state
740
+
741
+ def _embed_plan(
742
+ self, model: str, texts: list[str], input_type: str | None
743
+ ) -> list[tuple[Op, dict[str, Any]]]:
744
+ """(op, params) per provider batch for `texts`."""
745
+ _, cfg, _ = self.config.resolve(model)
746
+ size = max_batch_size(cfg.provider, cfg.model_id)
747
+ plan: list[tuple[Op, dict[str, Any]]] = []
748
+ for batch in batches(texts, size):
749
+ if cfg.provider == "bedrock":
750
+ options = (
751
+ {"input_type": input_type}
752
+ if input_type and "cohere" in cfg.model_id.lower()
753
+ else {}
754
+ )
755
+ plan.append(
756
+ ("invoke_model", {"body": embedding_body(cfg.model_id, batch, **options)})
757
+ )
758
+ else:
759
+ plan.append(("embeddings", {"input": batch}))
760
+ return plan
761
+
762
+ def _envelope(
763
+ self,
764
+ op: Op,
765
+ model: str,
766
+ params: dict[str, Any],
767
+ lane: Lane | str | None,
768
+ fallback: FallbackPolicy | None,
769
+ timeout_secs: float | None,
770
+ deadline_at: float | None,
771
+ request_id: str | None,
772
+ *,
773
+ embed: bool = False,
774
+ parent_request_id: str | None = None,
775
+ ) -> Envelope:
776
+ """Build the envelope from `request_context` (lane, session, user) and lane timeouts."""
777
+ self.config.resolve(model) # unknown alias / target → ConfigError before anything runs
778
+ ctx = current_context()
779
+ call_lane = Lane(lane) if lane is not None else ctx.lane
780
+ waits = self.config.embed_timeout_secs if embed else self.config.timeout_secs
781
+ now = time.time()
782
+ deadline = now + (timeout_secs if timeout_secs is not None else waits[call_lane])
783
+ if deadline_at is not None:
784
+ deadline = min(deadline, deadline_at)
785
+ return Envelope(
786
+ request_id=request_id or _new_request_id(),
787
+ parent_request_id=parent_request_id,
788
+ session_id=ctx.session_id,
789
+ user_id=ctx.user_id,
790
+ reply_to=self.pod_id,
791
+ lane=call_lane,
792
+ op=op,
793
+ fallback=fallback or "same_model",
794
+ model=model,
795
+ params=params,
796
+ enqueued_at=now,
797
+ deadline_at=max(deadline, now),
798
+ )
799
+
800
+ def _route(self, alias: str, envelope: Envelope) -> CallPath:
801
+ """`direct` while failed over or Valkey is down, else the router, else sqs-if-queued."""
802
+ state = self._state
803
+ if state is None or alias not in state.brokers.by_alias:
804
+ return "direct"
805
+ failover = self.failover
806
+ if failover.redis_down() or failover.path_override(alias):
807
+ return "direct"
808
+ router = self.router or self._own_router
809
+ if router is not None:
810
+ return router.route(alias, envelope.lane, envelope.session_id, envelope.user_id)
811
+ return "sqs"
812
+
813
+ async def _call(
814
+ self,
815
+ op: Op,
816
+ model: str,
817
+ params: dict[str, Any],
818
+ lane: Lane | str | None,
819
+ fallback: FallbackPolicy | None,
820
+ timeout_secs: float | None,
821
+ deadline_at: float | None,
822
+ request_id: str | None,
823
+ *,
824
+ embed: bool = False,
825
+ parent_request_id: str | None = None,
826
+ ) -> LLMReply:
827
+ """Build, route and run one call; raises the mapped error for an `ok=False` reply."""
828
+ envelope = self._envelope(
829
+ op,
830
+ model,
831
+ params,
832
+ lane,
833
+ fallback,
834
+ timeout_secs,
835
+ deadline_at,
836
+ request_id,
837
+ embed=embed,
838
+ parent_request_id=parent_request_id,
839
+ )
840
+ alias = model.partition("@")[0]
841
+ path = self._route(alias, envelope)
842
+ t0 = time.monotonic()
843
+ ok = False
844
+ try:
845
+ if path == "sqs":
846
+ reply = await self._via_sqs(alias, envelope, embed=embed)
847
+ else:
848
+ reply = await self._direct(envelope)
849
+ result = _unwrap(reply)
850
+ ok = True
851
+ return result
852
+ finally:
853
+ secs = time.monotonic() - t0
854
+ safe_hook(self.hooks.on_call, alias, envelope.lane.value, path, ok, secs)
855
+
856
+ async def _via_sqs(self, alias: str, envelope: Envelope, *, embed: bool) -> LLMReply:
857
+ """Kiq to the model's lane queue and await the reply (§6.1); failover to `direct`."""
858
+ state = self._require_started()
859
+ broker = state.brokers.by_alias[alias]
860
+ queue = queue_for_lane(self.config.models[alias], envelope.lane)
861
+ assert queue is not None
862
+ sent = envelope
863
+ try:
864
+ sent = await self._maybe_by_reference(state, envelope)
865
+ except RedisError as exc:
866
+ self._report_redis(exc)
867
+ return await self._direct(envelope)
868
+ labels = {
869
+ **task_labels(sent, hash_ids=self.config.hash_ids, salt=self.config.id_hash_key),
870
+ "queue_name": queue_name(queue),
871
+ "expiry": repr(sent.deadline_at),
872
+ }
873
+ task = broker.find_task(TASK_EMBED if embed else TASK_CALL)
874
+ assert task is not None
875
+ send = asyncio.ensure_future(
876
+ task.kicker()
877
+ .with_task_id(sent.request_id)
878
+ .with_labels(**labels)
879
+ .kiq(sent.model_dump(mode="json"))
880
+ )
881
+ try:
882
+ await self._bounded_send(alias, send, envelope.deadline_at)
883
+ except asyncio.CancelledError: # a batched send may still go out: flag it (DS14)
884
+ send.cancel()
885
+ await asyncio.shield(self._flag_cancel(state, envelope.request_id))
886
+ raise
887
+ except (
888
+ SendTaskError,
889
+ SQSSendError,
890
+ QueueUnavailable,
891
+ OSError, # includes TimeoutError from the bounded send (ambiguous: DS17 claim)
892
+ ) as exc: # taskiq wraps kick errors
893
+ rejected = _deterministic_send_error(exc)
894
+ if rejected is not None: # the same message would fail again: no failover
895
+ logger.warning(
896
+ "sqs_send_rejected",
897
+ request_id=envelope.request_id,
898
+ model=alias,
899
+ error_type=type(rejected).__name__,
900
+ )
901
+ if isinstance(rejected, LLMQueueError):
902
+ raise rejected from exc
903
+ raise LLMQueueError(f"send rejected: {type(rejected).__name__}") from exc
904
+ logger.warning(
905
+ "sqs_send_failed",
906
+ request_id=envelope.request_id,
907
+ model=alias,
908
+ lane=envelope.lane.value,
909
+ error_type=type(exc).__name__,
910
+ )
911
+ self.failover.report_sqs_failure(alias, exc)
912
+ return await self._direct(envelope, claim=True)
913
+ return await self._wait(state, envelope)
914
+
915
+ async def _bounded_send(
916
+ self, alias: str, send: asyncio.Future[Any], deadline_at: float
917
+ ) -> None:
918
+ """Await one SQS send; time out only when SQS sends stopped completing.
919
+
920
+ An unreachable SQS can hang a send on a dead pooled connection until botocore's 60 s
921
+ read timeout (S20), so a send gets `sqs_send_timeout_secs` and at most half the call
922
+ budget. But a send queued behind a burst is slow, not failed (S35b: 10K sends timed out
923
+ at 5 s and moved the sender to direct): while another send (any queue) completed
924
+ within the timeout, it keeps waiting up to the budget share. Raises `TimeoutError`.
925
+ """
926
+ limit = self.config.sqs_send_timeout_secs
927
+ give_up = time.monotonic() + max(0.0, _SEND_BUDGET_SHARE * (deadline_at - time.time()))
928
+ while True:
929
+ wait = max(0.0, min(limit, give_up - time.monotonic()))
930
+ done, _ = await asyncio.wait({send}, timeout=wait)
931
+ if done:
932
+ send.result() # raises the send's own error
933
+ self._sent_at = time.monotonic()
934
+ return
935
+ now = time.monotonic()
936
+ if now >= give_up or now - self._sent_at >= limit:
937
+ send.cancel()
938
+ raise TimeoutError(f"SQS send for {alias} timed out")
939
+
940
+ async def _wait(self, state: _Started, envelope: Envelope) -> LLMReply:
941
+ """Await the dispatcher; on timeout or caller cancellation set the cancel flag."""
942
+ remaining = max(0.0, envelope.deadline_at - time.time())
943
+ try:
944
+ reply = await state.dispatcher.wait(envelope.request_id, remaining, envelope.lane)
945
+ except ResultsUnavailable:
946
+ # Valkey gone for `valkey_wait_failover_secs`: no worker can claim the queued copy
947
+ # and no result can arrive, so serve the call here under the local cap (S21).
948
+ logger.warning("valkey_down_wait_direct", request_id=envelope.request_id)
949
+ await self._flag_cancel(state, envelope.request_id)
950
+ return await self._direct(envelope)
951
+ except LLMQueueTimeout:
952
+ await self._flag_cancel(state, envelope.request_id)
953
+ raise
954
+ except asyncio.CancelledError:
955
+ await asyncio.shield(self._flag_cancel(state, envelope.request_id))
956
+ raise
957
+ self._redis_ok() # the reply came through Valkey
958
+ return reply
959
+
960
+ async def _flag_cancel(self, state: _Started, request_id: str) -> None:
961
+ """Set the DS14 cancel flag; while Valkey is down, queue it for the recovery probe."""
962
+ if self.failover.redis_down(): # a Valkey call now would only add connect timeouts
963
+ self._pending_cancels.add(request_id)
964
+ return
965
+ with contextlib.suppress(Exception):
966
+ await state.claims.cancel(request_id)
967
+
968
+ async def _maybe_by_reference(self, state: _Started, envelope: Envelope) -> Envelope:
969
+ """Store params over `inline_max_bytes` in Valkey and send `params_ref` instead (D48)."""
970
+ size = len(envelope.model_dump_json().encode())
971
+ if size <= self.config.inline_max_bytes:
972
+ return envelope
973
+ key = self.keys.params(envelope.request_id)
974
+ await state.redis.set(key, orjson.dumps(envelope.params), ex=self.config.state_ttl_secs)
975
+ return envelope.model_copy(update={"params": None, "params_ref": key})
976
+
977
+ def _report_redis(self, exc: BaseException) -> None:
978
+ self.failover.report_redis_failure(exc)
979
+
980
+ def _direct_deps(self) -> CoreDeps:
981
+ """Deps for an in-process call: shared limiter + breaker, or the local cap (DS18)."""
982
+ state = self._require_started()
983
+ if self.failover.redis_down():
984
+ return CoreDeps(
985
+ config=self.config,
986
+ keys=self.keys,
987
+ providers=self.registry.for_model,
988
+ limiter=self._local_limiter,
989
+ worker=f"direct:{self.pod_id}",
990
+ hooks=self.hooks,
991
+ )
992
+ return CoreDeps(
993
+ config=self.config,
994
+ keys=self.keys,
995
+ providers=self.registry.for_model,
996
+ limiter=state.limiter,
997
+ claims=state.claims,
998
+ breaker=state.breaker,
999
+ redis=state.redis,
1000
+ fallback=lambda: state.fallback.current,
1001
+ worker=f"direct:{self.pod_id}",
1002
+ hooks=self.hooks,
1003
+ leases=self._leases,
1004
+ )
1005
+
1006
+ async def _direct(self, envelope: Envelope, *, claim: bool = False) -> LLMReply:
1007
+ """Run in-process; with `claim` (DS17) a queued copy that already runs is awaited.
1008
+
1009
+ The DS17 claim is heartbeated by the pod heartbeat while the call runs. If Valkey fails
1010
+ before any provider attempt the call runs again under the local cap; after one started,
1011
+ the caller gets `QueueUnavailable` (never a second provider call).
1012
+ """
1013
+ deps = self._direct_deps()
1014
+ rid = envelope.request_id
1015
+ held = claim and deps.claims is not None
1016
+ task = asyncio.current_task()
1017
+ if task is not None:
1018
+ self._direct_tasks[rid] = task
1019
+ if held:
1020
+ self._direct_claims[rid] = self._direct_claims.get(rid, 0) + 1
1021
+ try:
1022
+ try:
1023
+ result = await execute_job(envelope, deps, claim=held, store=False)
1024
+ except InfraError as exc:
1025
+ self._report_redis(exc.__cause__ or exc)
1026
+ if exc.attempted:
1027
+ raise QueueUnavailable(f"Valkey failed during call {rid}") from exc
1028
+ result = await self._direct_again(envelope)
1029
+ if held:
1030
+ await self._finish_direct_claim(rid, result.reply)
1031
+ finally:
1032
+ self._direct_tasks.pop(rid, None)
1033
+ if held:
1034
+ self._drop_direct_claim(rid)
1035
+ if result.reply is not None:
1036
+ return result.reply
1037
+ if result.decision is ClaimDecision.ACK_CANCELLED:
1038
+ raise LLMQueueCancelled(f"call {envelope.request_id} was cancelled")
1039
+ state = self._require_started()
1040
+ return await self._wait(state, envelope) # the queued copy runs elsewhere
1041
+
1042
+ async def _direct_again(self, envelope: Envelope) -> JobResult:
1043
+ """Second, unclaimed run after Valkey failed before any attempt (local cap, DS18)."""
1044
+ try:
1045
+ return await execute_job(envelope, self._direct_deps(), claim=False, store=False)
1046
+ except InfraError as exc:
1047
+ self._report_redis(exc.__cause__ or exc)
1048
+ raise QueueUnavailable(f"Valkey failed during call {envelope.request_id}") from exc
1049
+
1050
+ async def _finish_direct_claim(self, request_id: str, reply: LLMReply | None) -> None:
1051
+ """Best effort: mark a DS17 claim terminal after the local re-run answered the caller."""
1052
+ state = self._state
1053
+ if state is None or reply is None:
1054
+ return
1055
+ final = ClaimState.CANCELLED if reply.cancelled else ClaimState.DONE
1056
+ try:
1057
+ await state.claims.complete(request_id, final, worker=f"direct:{self.pod_id}")
1058
+ except Exception as exc:
1059
+ logger.warning(
1060
+ "claim_complete_failed", request_id=request_id, error_type=type(exc).__name__
1061
+ )
1062
+
1063
+ def _drop_direct_claim(self, request_id: str) -> None:
1064
+ left = self._direct_claims.get(request_id, 0) - 1
1065
+ if left > 0:
1066
+ self._direct_claims[request_id] = left
1067
+ else:
1068
+ self._direct_claims.pop(request_id, None)
1069
+
1070
+ def _run_sync(
1071
+ self,
1072
+ model: str,
1073
+ op: Op,
1074
+ params: dict[str, Any],
1075
+ lane: Lane | str | None,
1076
+ fallback: FallbackPolicy | None,
1077
+ timeout_secs: float | None,
1078
+ deadline_at: float | None,
1079
+ request_id: str | None,
1080
+ *,
1081
+ embed: bool = False,
1082
+ ) -> LLMReply:
1083
+ """Run one call `direct` on the private sync loop; counts `llmq_sync_fallback_total`."""
1084
+ if self.config.strict_async:
1085
+ raise LLMQueueError(f"sync {op} call refused: strict_async is set")
1086
+ envelope = self._envelope(
1087
+ op, model, params, lane, fallback, timeout_secs, deadline_at, request_id, embed=embed
1088
+ )
1089
+ self.sync_fallback_count += 1
1090
+ safe_hook(self.hooks.on_sync_fallback, model.partition("@")[0])
1091
+ with self._sync_lock:
1092
+ if self._sync is None:
1093
+ self._sync = _SyncRunner(self)
1094
+ runner = self._sync
1095
+ reply = runner.run(runner.execute(envelope), envelope.deadline_at - time.time() + 5)
1096
+ return _unwrap(reply)
1097
+
1098
+ def _redis_name(self) -> str | None:
1099
+ """`CLIENT SETNAME` value: the configured name plus this pod's id."""
1100
+ base = self.config.redis_client_name
1101
+ return f"{base}:{self.pod_id}" if base else None
1102
+
1103
+
1104
+ class _LocalLimiters:
1105
+ """`Limiter` for Valkey-down mode (DS18): the failover policy's per-target local caps.
1106
+
1107
+ Uses `FailoverState.local_limiter(model, target)` when the policy has it, else one
1108
+ `LocalLimiter` capped at `local_limit_cap`. A lease goes back to the limiter that issued it,
1109
+ even after the policy rebuilt its limiters.
1110
+ """
1111
+
1112
+ def __init__(self, failover: FailoverPolicy, config: QueueConfig) -> None:
1113
+ self._failover = failover
1114
+ self._fallback = LocalLimiter(
1115
+ config.local_limit_cap,
1116
+ bg_share=config.limiter.bg_share,
1117
+ pause_secs=config.limiter.pause_secs,
1118
+ )
1119
+ self._issued: dict[str, LocalLimiter] = {}
1120
+
1121
+ def _for(self, model: str, target: str) -> LocalLimiter:
1122
+ factory = getattr(self._failover, "local_limiter", None)
1123
+ limiter = factory(model, target) if callable(factory) else None
1124
+ return limiter if isinstance(limiter, LocalLimiter) else self._fallback
1125
+
1126
+ async def acquire(
1127
+ self,
1128
+ model: str,
1129
+ target: str,
1130
+ lane: Lane,
1131
+ timeout_secs: float,
1132
+ *,
1133
+ should_abort: Callable[[], Awaitable[bool]] | None = None,
1134
+ ) -> Lease | None:
1135
+ limiter = self._for(model, target)
1136
+ lease = await limiter.acquire(model, target, lane, timeout_secs, should_abort=should_abort)
1137
+ if lease is not None:
1138
+ self._issued[lease.token] = limiter
1139
+ return lease
1140
+
1141
+ async def release(self, lease: Lease, *, throttled: bool) -> None:
1142
+ limiter = self._issued.pop(lease.token, None) or self._for(lease.model, lease.target)
1143
+ await limiter.release(lease, throttled=throttled)
1144
+
1145
+ def gate(self, model: str, target: str) -> Any:
1146
+ return self._for(model, target).gate(model, target)
1147
+
1148
+
1149
+ class _SyncLimiter:
1150
+ """`Limiter` over `SyncAdaptiveLimiter`, run in threads so the private loop never blocks."""
1151
+
1152
+ def __init__(self, inner: SyncAdaptiveLimiter) -> None:
1153
+ self._inner = inner
1154
+ self._orphans: set[asyncio.Future[None]] = set()
1155
+
1156
+ async def acquire(
1157
+ self,
1158
+ model: str,
1159
+ target: str,
1160
+ lane: Lane,
1161
+ timeout_secs: float,
1162
+ *,
1163
+ should_abort: Callable[[], Awaitable[bool]] | None = None,
1164
+ ) -> Lease | None:
1165
+ """Take a slot in a thread; a lease granted after the caller gave up is released."""
1166
+ abort = _sync_abort(should_abort, asyncio.get_running_loop())
1167
+ future = asyncio.ensure_future(
1168
+ asyncio.to_thread(
1169
+ self._inner.acquire, model, target, lane, timeout_secs, should_abort=abort
1170
+ )
1171
+ )
1172
+ try:
1173
+ return await asyncio.shield(future)
1174
+ except asyncio.CancelledError: # sync timeout: the thread may still win a slot
1175
+ future.add_done_callback(self._release_orphan)
1176
+ raise
1177
+
1178
+ def _release_orphan(self, future: asyncio.Future[Lease | None]) -> None:
1179
+ if future.cancelled() or future.exception() is not None:
1180
+ return
1181
+ lease = future.result()
1182
+ if lease is not None:
1183
+ self._orphans.add(asyncio.ensure_future(self.release(lease, throttled=False)))
1184
+ for done in [f for f in self._orphans if f.done()]:
1185
+ self._orphans.discard(done)
1186
+
1187
+ async def release(self, lease: Lease, *, throttled: bool) -> None:
1188
+ await asyncio.to_thread(self._inner.release, lease, throttled=throttled)
1189
+
1190
+ def gate(self, model: str, target: str) -> Any:
1191
+ raise NotImplementedError("sync limiter has no receive gate")
1192
+
1193
+
1194
+ def _sync_abort(
1195
+ should_abort: Callable[[], Awaitable[bool]] | None, loop: asyncio.AbstractEventLoop
1196
+ ) -> Callable[[], bool] | None:
1197
+ """Run an async `should_abort` on `loop` from the limiter's thread (False on any error)."""
1198
+ if should_abort is None:
1199
+ return None
1200
+
1201
+ async def call() -> bool:
1202
+ return await should_abort()
1203
+
1204
+ def check() -> bool:
1205
+ try:
1206
+ return asyncio.run_coroutine_threadsafe(call(), loop).result(5)
1207
+ except Exception:
1208
+ return False
1209
+
1210
+ return check
1211
+
1212
+
1213
+ class _SyncRunner:
1214
+ """A daemon thread with its own event loop and providers for sync callers (R22).
1215
+
1216
+ Providers bind their HTTP clients to a loop, so sync calls get their own registry; the
1217
+ limiter is the shared Valkey one through a blocking client (or the local cap, DS18).
1218
+ """
1219
+
1220
+ def __init__(self, owner: LLMQueue) -> None:
1221
+ self._owner = owner
1222
+ self._loop = asyncio.new_event_loop()
1223
+ self._thread = threading.Thread(
1224
+ target=self._loop.run_forever, name="llmq-sync", daemon=True
1225
+ )
1226
+ self._thread.start()
1227
+ self._registry = ProviderRegistry(owner.config, factories=owner._factories)
1228
+ self._redis: redis.Redis | None = None
1229
+
1230
+ def run(self, coro: Coroutine[Any, Any, _T], timeout_secs: float) -> _T:
1231
+ """Run `coro` on the private loop and block for its result."""
1232
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
1233
+ try:
1234
+ return future.result(max(1.0, timeout_secs))
1235
+ except TimeoutError as exc:
1236
+ future.cancel()
1237
+ raise LLMQueueTimeout("sync call timed out") from exc
1238
+
1239
+ def _limiter(self) -> Limiter:
1240
+ owner = self._owner
1241
+ if owner.failover.redis_down():
1242
+ return owner._local_limiter
1243
+ if self._redis is None:
1244
+ self._redis = create_sync_redis(owner.config, client_name=owner._redis_name())
1245
+ return _SyncLimiter(
1246
+ SyncAdaptiveLimiter(self._redis, owner.keys, limiter_settings(owner.config))
1247
+ )
1248
+
1249
+ async def execute(self, envelope: Envelope) -> LLMReply:
1250
+ owner = self._owner
1251
+ deps = CoreDeps(
1252
+ config=owner.config,
1253
+ keys=owner.keys,
1254
+ providers=self._registry.for_model,
1255
+ limiter=self._limiter(),
1256
+ worker=f"sync:{owner.pod_id}",
1257
+ hooks=owner.hooks,
1258
+ leases=owner._leases, # renewed by the pod heartbeat (DS11)
1259
+ )
1260
+ try:
1261
+ result = await execute_job(envelope, deps, claim=False, store=False)
1262
+ except InfraError as exc:
1263
+ owner._report_redis(exc.__cause__ or exc)
1264
+ if exc.attempted: # never a second provider call
1265
+ raise QueueUnavailable(f"Valkey failed during call {envelope.request_id}") from exc
1266
+ deps.limiter = owner._local_limiter
1267
+ result = await execute_job(envelope, deps, claim=False, store=False)
1268
+ assert result.reply is not None
1269
+ return result.reply
1270
+
1271
+ def close(self) -> None:
1272
+ """Close providers, stop the loop and the thread."""
1273
+ with contextlib.suppress(Exception):
1274
+ asyncio.run_coroutine_threadsafe(self._registry.aclose(), self._loop).result(10)
1275
+ self._loop.call_soon_threadsafe(self._loop.stop)
1276
+ self._thread.join(timeout=10)
1277
+ if self._redis is not None:
1278
+ self._redis.close()
1279
+
1280
+
1281
+ def decode_invoke(response: Mapping[str, Any]) -> dict[str, Any]:
1282
+ """`{"body_b64", "contentType"}` from a worker → `{"body": bytes, "contentType": str}`."""
1283
+ return {
1284
+ "body": base64.b64decode(response.get("body_b64", "")),
1285
+ "contentType": str(response.get("contentType", "")),
1286
+ }
1287
+
1288
+
1289
+ def _new_request_id() -> str:
1290
+ return f"llm-{uuid.uuid4().hex}"
1291
+
1292
+
1293
+ def _new_aws_session() -> AioSession:
1294
+ """A fresh aiobotocore session (credential chain resolved once, shared by its clients)."""
1295
+ from aiobotocore.session import get_session
1296
+
1297
+ return get_session()
1298
+
1299
+
1300
+ def _batch_ids(request_id: str, count: int) -> list[str]:
1301
+ """Request id per embed batch: the id itself for one batch, else `<id>-<n>`."""
1302
+ return [request_id] if count == 1 else [f"{request_id}-{i}" for i in range(count)]
1303
+
1304
+
1305
+ def _invoke_body(body: Mapping[str, Any] | list[Any] | str | bytes) -> Any:
1306
+ """Envelope-safe InvokeModel body: JSON values as-is, bytes as UTF-8 text."""
1307
+ if isinstance(body, bytes):
1308
+ try:
1309
+ return body.decode("utf-8")
1310
+ except UnicodeDecodeError as exc:
1311
+ raise ValueError("invoke_model body bytes must be UTF-8 text") from exc
1312
+ if isinstance(body, Mapping):
1313
+ return dict(body)
1314
+ return body
1315
+
1316
+
1317
+ def _invoke_result(reply: LLMReply) -> InvokeResult:
1318
+ raw = decode_invoke(reply.response or {})
1319
+ return InvokeResult(raw["body"], raw["contentType"], reply.meta)
1320
+
1321
+
1322
+ def _merge_meta(metas: Sequence[CallMeta]) -> CallMeta:
1323
+ """One `CallMeta` for several batches: first model/target, summed attempts, slowest waits."""
1324
+ if not metas:
1325
+ return CallMeta()
1326
+ first = metas[0]
1327
+ return CallMeta(
1328
+ served_model=first.served_model,
1329
+ served_target=first.served_target,
1330
+ fallback_used=any(m.fallback_used for m in metas),
1331
+ queue_wait_ms=max(m.queue_wait_ms for m in metas),
1332
+ provider_ms=max(m.provider_ms for m in metas),
1333
+ attempts=sum(m.attempts for m in metas),
1334
+ worker=first.worker,
1335
+ )
1336
+
1337
+
1338
+ def _embed_result(plan: Sequence[tuple[Op, Any]], replies: Sequence[LLMReply]) -> EmbedResult:
1339
+ vectors = [v for (op, _), reply in zip(plan, replies, strict=True) for v in _vectors(op, reply)]
1340
+ return EmbedResult(vectors, _merge_meta([r.meta for r in replies]))
1341
+
1342
+
1343
+ def _vectors(op: Op, reply: LLMReply) -> list[list[float]]:
1344
+ """Vectors from one embed reply: Bedrock InvokeModel body or OpenAI embeddings `data`."""
1345
+ response = reply.response or {}
1346
+ if op == "invoke_model":
1347
+ return parse_embeddings(orjson.loads(decode_invoke(response)["body"]))
1348
+ data = sorted(response.get("data", []), key=lambda item: item.get("index", 0))
1349
+ return [list(item["embedding"]) for item in data]
1350
+
1351
+
1352
+ def _deterministic_send_error(exc: BaseException) -> BaseException | None:
1353
+ """The cause of a send that would fail again (bad label, too large, config), else None.
1354
+
1355
+ Only infrastructure failures (SQS down, throttled, timeouts) open the SQS failover window.
1356
+ """
1357
+ seen: BaseException | None = exc
1358
+ for _ in range(5):
1359
+ if seen is None:
1360
+ return None
1361
+ if isinstance(seen, SQSSendError):
1362
+ return seen if seen.family is ErrorFamily.SIZE_LIMIT else None
1363
+ if isinstance(seen, ValueError | TypeError | ConfigError):
1364
+ return seen
1365
+ seen = seen.__cause__
1366
+ return None
1367
+
1368
+
1369
+ def _unwrap(reply: LLMReply) -> LLMReply:
1370
+ """Return an ok reply; map an error reply to `LLMQueueTimeout` / `ProviderError`."""
1371
+ if reply.ok:
1372
+ return reply
1373
+ error = reply.error or {}
1374
+ code = str(error.get("type", "error"))
1375
+ message = str(error.get("message", code))
1376
+ if reply.cancelled or code == "cancelled":
1377
+ raise LLMQueueCancelled(f"call {reply.request_id} was cancelled")
1378
+ if code == "deadline_exceeded":
1379
+ raise LLMQueueTimeout(f"call {reply.request_id} ran past its deadline")
1380
+ retry_after = error.get("retry_after_secs")
1381
+ exc = ProviderError(
1382
+ _error_text(code, message),
1383
+ retryable=bool(error.get("retryable", False)),
1384
+ retry_after_secs=float(retry_after) if retry_after is not None else None,
1385
+ )
1386
+ exc.status_code = _STATUS_BY_KIND.get(str(error.get("kind", "")), 502) # type: ignore[attr-defined] # optional attribute read by http_transport
1387
+ raise exc
1388
+
1389
+
1390
+ def _error_text(code: str, message: str) -> str:
1391
+ """`code: message` without repeating the code (`ThrottlingException: ThrottlingException`)."""
1392
+ parts: list[str] = []
1393
+ for part in [code, *message.split(": ")]:
1394
+ if part and part not in parts:
1395
+ parts.append(part)
1396
+ return ": ".join(parts) or "error"
1397
+
1398
+
1399
+ def _set_default_queue(llmq: LLMQueue | None, *, only: LLMQueue | None = None) -> None:
1400
+ """Register (or clear) the adapters' default queue; clearing only if it is still `only`."""
1401
+ from taskiq_llm_queue.langchain._client import get_default_queue, set_default_queue
1402
+
1403
+ if only is not None:
1404
+ try:
1405
+ if get_default_queue() is not only:
1406
+ return
1407
+ except LLMQueueError:
1408
+ return
1409
+ set_default_queue(llmq)