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,453 @@
1
+ """The taskiq tasks every worker registers: `taskiq_llm_queue.call`, `.embed` and `.job`.
2
+
3
+ Names are stable for all of 0.x (STABILITY.md). LLM tasks run `core.execute_job`, which stores
4
+ the reply itself; the task returns None and `WorkerResultBackend` skips taskiq's own write, so
5
+ a result is written exactly once (DS8). Jobs run app-registered functions from an allow-list.
6
+ No exception leaves a task body: taskiq would ack (delete) the message, so every infrastructure
7
+ failure releases the message for redelivery instead.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import time
15
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from redis.exceptions import RedisError
20
+ from taskiq import AsyncResultBackend, Context, TaskiqDepends, TaskiqResult
21
+
22
+ from taskiq_llm_queue._logging import get_logger
23
+ from taskiq_llm_queue.broker.broker import (
24
+ SQS_MESSAGE_ID_LABEL,
25
+ SQS_RECEIPT_HANDLE_LABEL,
26
+ SQS_RECEIVE_COUNT_LABEL,
27
+ )
28
+ from taskiq_llm_queue.core import InfraError, execute_job
29
+ from taskiq_llm_queue.envelope import (
30
+ TASK_CALL,
31
+ TASK_EMBED,
32
+ TASK_JOB,
33
+ LLMReply,
34
+ parse_envelope,
35
+ )
36
+ from taskiq_llm_queue.errors import QueueUnavailable, UnsupportedEnvelopeError
37
+ from taskiq_llm_queue.protocols import ClaimDecision, ClaimState
38
+
39
+ if TYPE_CHECKING:
40
+ from taskiq_llm_queue.broker.broker import HybridSQSBroker
41
+ from taskiq_llm_queue.broker.delivery import Delivery
42
+ from taskiq_llm_queue.core import CoreDeps
43
+ from taskiq_llm_queue.results.backend import LLMQResultBackend
44
+ from taskiq_llm_queue.routing_hooks import FailoverPolicy
45
+
46
+ logger = get_logger(__name__)
47
+
48
+ JOB_VERSION = 1
49
+ JobFunction = Callable[[dict[str, Any]], Awaitable[Any]]
50
+ _UNSUPPORTED_RELEASE_SECS = 30
51
+ # DS5: an exhausted job is released quickly until `maxReceiveCount` moves it to the DLQ.
52
+ _DLQ_RELEASE_SECS = 1
53
+ _CONTEXT: Any = TaskiqDepends() # taskiq injects the message Context into the task functions
54
+
55
+ # `_Running.stop_reason` values set by the broker heartbeat and the pod heartbeat.
56
+ LOST = "lost" # SQS receipt handle invalid (DS6): the message is no longer ours
57
+ CLAIM_LOST = "claim_lost" # another pod owns the claim now: never delete the message
58
+
59
+
60
+ class WorkerResultBackend(AsyncResultBackend[Any]):
61
+ """Result backend given to the brokers: skips taskiq's write for tasks that stored their own.
62
+
63
+ LLM tasks return None after `execute_job` stored the reply; a task that raised unexpectedly
64
+ gets an `ok=False` reply so its waiter is released. Cancellations write nothing.
65
+ """
66
+
67
+ def __init__(self, inner: LLMQResultBackend) -> None:
68
+ """Wrap the shared JSON backend (its user count tracks every broker)."""
69
+ super().__init__()
70
+ self.inner = inner
71
+
72
+ async def startup(self) -> None:
73
+ """Register one user of the shared backend."""
74
+ await self.inner.startup()
75
+
76
+ async def shutdown(self) -> None:
77
+ """Drop one user of the shared backend."""
78
+ await self.inner.shutdown()
79
+
80
+ async def set_result(self, task_id: str, result: TaskiqResult[Any]) -> None:
81
+ """Store only unexpected task errors, never over a reply the task already stored."""
82
+ error = result.error
83
+ if not result.is_err or isinstance(error, asyncio.CancelledError):
84
+ return
85
+ with contextlib.suppress(Exception):
86
+ if await self.inner.is_result_ready(task_id):
87
+ return
88
+ reply = LLMReply(
89
+ request_id=task_id,
90
+ ok=False,
91
+ error={"type": "task_error", "message": type(error).__name__, "retryable": True},
92
+ )
93
+ await self.inner.put(reply, ttl_secs=self.inner.result_ttl_secs)
94
+
95
+ async def is_result_ready(self, task_id: str) -> bool:
96
+ """Delegate to the shared backend."""
97
+ return await self.inner.is_result_ready(task_id)
98
+
99
+ async def get_result(self, task_id: str, with_logs: bool = False) -> TaskiqResult[Any]:
100
+ """Delegate to the shared backend."""
101
+ return await self.inner.get_result(task_id, with_logs)
102
+
103
+
104
+ @dataclass(slots=True, eq=False)
105
+ class _Running:
106
+ """One claimed task this pod runs: its asyncio task, broker and SQS receipt handle."""
107
+
108
+ request_id: str
109
+ task: asyncio.Task[Any]
110
+ broker: HybridSQSBroker
111
+ receipt_handle: str | None
112
+ message_id: str | None
113
+ stop_reason: str | None = None
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class WorkerRuntime:
118
+ """Per-`LLMQueue` worker state shared by every registered task.
119
+
120
+ Tracks claimed, running deliveries (one entry each, so two deliveries of one request never
121
+ clobber each other) for drain, the pod heartbeat and the broker's `on_lost` (DS6).
122
+ """
123
+
124
+ deps: CoreDeps
125
+ jobs: dict[str, JobFunction] = field(default_factory=dict)
126
+ running: dict[tuple[str, int], _Running] = field(default_factory=dict)
127
+ defer_secs: int = 20
128
+ failover: FailoverPolicy | None = None
129
+
130
+ def register(
131
+ self, request_id: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
132
+ ) -> _Running:
133
+ """Record the current asyncio task as running `request_id` (call only after RUN)."""
134
+ task = asyncio.current_task()
135
+ assert task is not None
136
+ entry = _Running(
137
+ request_id,
138
+ task,
139
+ broker,
140
+ _label(labels, SQS_RECEIPT_HANDLE_LABEL),
141
+ _label(labels, SQS_MESSAGE_ID_LABEL),
142
+ )
143
+ self.running[(request_id, id(entry))] = entry
144
+ return entry
145
+
146
+ def unregister(self, entry: _Running) -> None:
147
+ """Drop exactly this delivery's entry."""
148
+ self.running.pop((entry.request_id, id(entry)), None)
149
+
150
+ def running_ids(self) -> list[str]:
151
+ """Distinct request ids of running tasks (for the claim heartbeat)."""
152
+ return list(dict.fromkeys(entry.request_id for entry in self.running.values()))
153
+
154
+ def on_lost(self, delivery: Delivery) -> None:
155
+ """Broker heartbeat lost the message (DS6): cancel its task; it writes no result."""
156
+ for entry in list(self.running.values()):
157
+ if entry.message_id == delivery.message_id and not entry.task.done():
158
+ entry.stop_reason = LOST
159
+ entry.task.cancel()
160
+
161
+ def stop_running(self, reason: str, request_ids: Sequence[str] | None = None) -> list[Any]:
162
+ """Cancel running tasks (all, or the given ids); returns the tasks to await."""
163
+ chosen = [
164
+ entry
165
+ for entry in list(self.running.values())
166
+ if request_ids is None or entry.request_id in request_ids
167
+ ]
168
+ for entry in chosen:
169
+ if not entry.task.done():
170
+ entry.stop_reason = entry.stop_reason or reason
171
+ entry.task.cancel()
172
+ return [entry.task for entry in chosen]
173
+
174
+ async def run_llm(self, raw: Any, labels: Mapping[str, Any], broker: HybridSQSBroker) -> None:
175
+ """Body of the `call` / `embed` tasks: parse, execute, release instead of losing work."""
176
+ try:
177
+ envelope = parse_envelope(raw)
178
+ except UnsupportedEnvelopeError:
179
+ await _release(broker, labels, _UNSUPPORTED_RELEASE_SECS)
180
+ return
181
+ except ValueError as exc:
182
+ logger.error("poison_task_dropped", error_type=type(exc).__name__)
183
+ await self._reply_poison(raw)
184
+ return
185
+ rid = envelope.request_id
186
+ owned: list[_Running] = []
187
+
188
+ def on_run() -> None:
189
+ owned.append(self.register(rid, broker, labels))
190
+
191
+ try:
192
+ result = await execute_job(
193
+ envelope, self.deps, on_run=on_run, deliveries=_receive_count(labels)
194
+ )
195
+ if result.decision is ClaimDecision.DEFER:
196
+ await _release(broker, labels, self.defer_secs) # DS2: never delete
197
+ elif result.requeue_secs is not None: # DS4 no capacity; S48 model not configured
198
+ await _release(broker, labels, result.requeue_secs)
199
+ except InfraError as exc:
200
+ if owned: # S50: a claim-loss cancel here skipped the release
201
+ self.unregister(owned[0])
202
+ await self._infra_release(rid, exc, broker, labels, owned=bool(owned))
203
+ except QueueUnavailable: # DS8: reply cached on this pod, claim left running
204
+ logger.warning(
205
+ "result_not_stored", request_id=rid, model=envelope.model, lane=envelope.lane.value
206
+ )
207
+ await _release(broker, labels, self.defer_secs)
208
+ except asyncio.CancelledError:
209
+ entry = owned[0] if owned else None
210
+ await asyncio.shield(self._after_cancel(rid, entry, broker, labels))
211
+ if _claim_loss_only(entry):
212
+ return
213
+ raise
214
+ except Exception as exc: # a bug: release (maxReceiveCount bounds it) rather than ack
215
+ logger.exception(
216
+ "task_failed_unexpectedly", request_id=rid, error_type=type(exc).__name__
217
+ )
218
+ await _release(broker, labels, self.defer_secs)
219
+ finally:
220
+ if owned:
221
+ self.unregister(owned[0])
222
+
223
+ async def _infra_release(
224
+ self,
225
+ request_id: str,
226
+ exc: BaseException,
227
+ broker: HybridSQSBroker,
228
+ labels: Mapping[str, Any],
229
+ *,
230
+ owned: bool,
231
+ ) -> None:
232
+ """Valkey failed: report it, release the message, then hand our claim back if held.
233
+
234
+ The message goes first (S50): the claim release can hang reconnecting to a dead Valkey,
235
+ and a task cancelled there before releasing was acked by taskiq, losing the call.
236
+ """
237
+ cause = exc.__cause__ or exc
238
+ self._report_redis(cause)
239
+ logger.warning(
240
+ "valkey_error_task_released",
241
+ request_id=request_id,
242
+ error_type=type(cause).__name__,
243
+ attempted=getattr(exc, "attempted", False),
244
+ )
245
+ await _release(broker, labels, self.defer_secs)
246
+ claims = self.deps.claims
247
+ if owned and claims is not None:
248
+ with contextlib.suppress(Exception):
249
+ await claims.release(request_id, worker=self.deps.worker)
250
+
251
+ def _report_redis(self, exc: BaseException) -> None:
252
+ if self.failover is not None and isinstance(exc, RedisError):
253
+ try:
254
+ self.failover.report_redis_failure(exc)
255
+ except Exception: # the policy must never break the worker
256
+ logger.exception("failover_report_failed")
257
+
258
+ async def _after_cancel(
259
+ self,
260
+ request_id: str,
261
+ entry: _Running | None,
262
+ broker: HybridSQSBroker,
263
+ labels: Mapping[str, Any],
264
+ ) -> None:
265
+ """Finish a cancelled delivery without deleting a message another pod may still need."""
266
+ claims = self.deps.claims
267
+ reason = entry.stop_reason if entry is not None else None
268
+ if entry is None: # cancelled before the claim said RUN: the claim may not be ours
269
+ await _release(broker, labels, self.defer_secs)
270
+ elif reason == LOST: # DS6: SQS gave the message away; free our claim for the new owner
271
+ if claims is not None:
272
+ with contextlib.suppress(Exception):
273
+ await claims.release(request_id, worker=self.deps.worker)
274
+ elif reason == CLAIM_LOST: # another pod runs it now: keep the message, never ack it
275
+ logger.warning("claim_lost_task_stopped", request_id=request_id)
276
+ await _release(broker, labels, self.defer_secs)
277
+ else: # drain leftover (§7.4): claim `released`, message visible now
278
+ await self._hand_back(request_id, broker, labels)
279
+
280
+ async def _hand_back(
281
+ self, request_id: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
282
+ ) -> None:
283
+ """Drain leftover: claim `released` and message visible now, so another pod starts it."""
284
+ claims = self.deps.claims
285
+ with contextlib.suppress(Exception):
286
+ if claims is not None:
287
+ await claims.release(request_id, worker=self.deps.worker)
288
+ await _release(broker, labels, 0)
289
+
290
+ async def _reply_poison(self, raw: Any) -> None:
291
+ """Release a waiter on a malformed envelope that still names its request id."""
292
+ rid = raw.get("request_id") if isinstance(raw, dict) else None
293
+ if isinstance(rid, str) and self.deps.results is not None:
294
+ reply = LLMReply(
295
+ request_id=rid,
296
+ ok=False,
297
+ error={"type": "invalid_envelope", "message": "malformed", "retryable": False},
298
+ )
299
+ with contextlib.suppress(Exception):
300
+ await self.deps.results.put(reply, ttl_secs=self.deps.config.result_ttl_secs)
301
+
302
+ async def run_job(self, raw: Any, labels: Mapping[str, Any], broker: HybridSQSBroker) -> None:
303
+ """Body of the `job` task: run an allow-listed app job once (claimed) by name.
304
+
305
+ A failing job is released with backoff; after `max_failures` it keeps being released
306
+ until SQS `maxReceiveCount` moves it to the DLQ (DS5). Valkey errors release it too.
307
+ """
308
+ if not isinstance(raw, dict) or not isinstance(raw.get("request_id"), str):
309
+ logger.error("poison_job_dropped")
310
+ return
311
+ job_id: str = raw["request_id"]
312
+ name = str(raw.get("job"))
313
+ function = self.jobs.get(name)
314
+ if function is None:
315
+ await _release(broker, labels, _UNSUPPORTED_RELEASE_SECS)
316
+ return
317
+ claims = self.deps.claims
318
+ deadline = float(raw.get("deadline_at") or time.time() + 86400)
319
+ try:
320
+ if claims is not None:
321
+ decision = await claims.claim(job_id, deadline_at=deadline, worker=self.deps.worker)
322
+ if decision is ClaimDecision.DEFER:
323
+ await _release(broker, labels, self.defer_secs)
324
+ return
325
+ if decision is not ClaimDecision.RUN:
326
+ return
327
+ if await self._failures_exhausted(job_id):
328
+ await self._to_dlq(job_id, name, broker, labels)
329
+ return
330
+ except RedisError as exc:
331
+ await self._infra_release(job_id, exc, broker, labels, owned=False)
332
+ return
333
+ entry = self.register(job_id, broker, labels)
334
+ try:
335
+ await function(dict(raw.get("payload") or {}))
336
+ except asyncio.CancelledError:
337
+ await asyncio.shield(self._after_cancel(job_id, entry, broker, labels))
338
+ if _claim_loss_only(entry):
339
+ return
340
+ raise
341
+ except Exception:
342
+ logger.exception("job_failed", job=name, request_id=job_id)
343
+ await self._job_failed(job_id, name, broker, labels)
344
+ return
345
+ finally:
346
+ self.unregister(entry)
347
+ if claims is not None: # the job ran; a failed CAS is logged, never re-runs it here
348
+ try:
349
+ await claims.complete(job_id, ClaimState.DONE, worker=self.deps.worker)
350
+ except Exception as exc:
351
+ logger.warning(
352
+ "claim_complete_failed", request_id=job_id, error_type=type(exc).__name__
353
+ )
354
+
355
+ async def _job_failed(
356
+ self, job_id: str, name: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
357
+ ) -> None:
358
+ """Count a real failure (DS5) and release the message with backoff, or toward the DLQ."""
359
+ claims = self.deps.claims
360
+ if claims is None:
361
+ await _release(broker, labels, _UNSUPPORTED_RELEASE_SECS)
362
+ return
363
+ try:
364
+ failures = await claims.record_failure(job_id)
365
+ except RedisError as exc:
366
+ await self._infra_release(job_id, exc, broker, labels, owned=True)
367
+ return
368
+ if failures.exhausted:
369
+ await self._to_dlq(job_id, name, broker, labels)
370
+ return
371
+ with contextlib.suppress(Exception):
372
+ await claims.release(job_id, worker=self.deps.worker)
373
+ await _release(broker, labels, min(900, 30 * 2**failures.count))
374
+
375
+ async def _failures_exhausted(self, job_id: str) -> bool:
376
+ """True when the claim record already counts `max_failures` real failures (DS5)."""
377
+ redis = self.deps.redis
378
+ if redis is None:
379
+ return False
380
+ raw = await redis.hget(self.deps.keys.job(job_id), "failures")
381
+ return raw is not None and int(raw) >= self.deps.config.max_failures
382
+
383
+ async def _to_dlq(
384
+ self, job_id: str, name: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
385
+ ) -> None:
386
+ """DS5: never ack an exhausted job; release it until `maxReceiveCount` dead-letters it."""
387
+ logger.error("job_failures_exhausted", job=name, request_id=job_id)
388
+ claims = self.deps.claims
389
+ if claims is not None:
390
+ with contextlib.suppress(Exception):
391
+ await claims.release(job_id, worker=self.deps.worker)
392
+ await _release(broker, labels, _DLQ_RELEASE_SECS)
393
+
394
+
395
+ def _label(labels: Mapping[str, Any], name: str) -> str | None:
396
+ value = labels.get(name)
397
+ return None if value is None else str(value)
398
+
399
+
400
+ def _claim_loss_only(entry: _Running | None) -> bool:
401
+ """True when the only cancel was our claim-loss stop: end quietly, not with a traceback.
402
+
403
+ The message is already released (a later taskiq ack is a no-op) and nothing is stored.
404
+ Shutdown or any other pending cancel keeps the count above 0, so it is re-raised.
405
+ """
406
+ if entry is None or entry.stop_reason != CLAIM_LOST:
407
+ return False
408
+ task = asyncio.current_task()
409
+ return task is not None and task.uncancel() == 0
410
+
411
+
412
+ def _receive_count(labels: Mapping[str, Any]) -> int:
413
+ """SQS `ApproximateReceiveCount` of this delivery (1 when absent or unparsable)."""
414
+ try:
415
+ return max(1, int(labels.get(SQS_RECEIVE_COUNT_LABEL) or 1))
416
+ except (TypeError, ValueError):
417
+ return 1
418
+
419
+
420
+ async def _release(broker: HybridSQSBroker, labels: Mapping[str, Any], secs: int) -> None:
421
+ """Make the current message visible again after `secs` instead of acking it."""
422
+ handle = _label(labels, SQS_RECEIPT_HANDLE_LABEL)
423
+ if handle is not None:
424
+ await broker.release(handle, visibility_secs=secs, reason="task")
425
+
426
+
427
+ def job_filter(runtime: WorkerRuntime) -> Callable[[Any], bool]:
428
+ """Broker message filter: job messages whose name this pod doesn't know are released."""
429
+
430
+ def accept(message: Any) -> bool:
431
+ if message.task_name != TASK_JOB:
432
+ return True
433
+ args = [*message.args, *message.kwargs.values()]
434
+ return any(isinstance(a, dict) and a.get("job") in runtime.jobs for a in args)
435
+
436
+ return accept
437
+
438
+
439
+ def register_tasks(broker: HybridSQSBroker, runtime: WorkerRuntime) -> None:
440
+ """Register the three stable task names on `broker`, bound to `runtime`."""
441
+
442
+ async def call(envelope: dict[str, Any], context: Context = _CONTEXT) -> None:
443
+ await runtime.run_llm(envelope, context.message.labels, broker)
444
+
445
+ async def embed(envelope: dict[str, Any], context: Context = _CONTEXT) -> None:
446
+ await runtime.run_llm(envelope, context.message.labels, broker)
447
+
448
+ async def job(payload: dict[str, Any], context: Context = _CONTEXT) -> None:
449
+ await runtime.run_job(payload, context.message.labels, broker)
450
+
451
+ broker.register_task(call, task_name=TASK_CALL)
452
+ broker.register_task(embed, task_name=TASK_EMBED)
453
+ broker.register_task(job, task_name=TASK_JOB)