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,105 @@
1
+ """Taskiq result backend on S3: JSON results at `s3://bucket/<prefix>/<task_id>.json` (P10).
2
+
3
+ For results that are large or must outlive Redis. S3 has no per-object TTL, so expiry is an
4
+ S3 lifecycle rule on `prefix` (e.g. expire after 1 day), set up outside this library.
5
+ Adapted from taskiq-sqs `S3ResultBackend` (MIT, see NOTICE). Results are JSON, never pickle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from botocore.exceptions import ClientError
13
+ from taskiq import AsyncResultBackend, TaskiqResult
14
+ from taskiq.exceptions import ResultGetError
15
+
16
+ from taskiq_llm_queue.broker.s3_offload import S3Connection, is_missing, object_key
17
+
18
+ if TYPE_CHECKING:
19
+ from aiobotocore.config import AioConfig
20
+ from aiobotocore.session import AioSession
21
+ from types_aiobotocore_s3 import S3Client
22
+
23
+
24
+ class S3ResultBackend(AsyncResultBackend[Any]):
25
+ """Stores each `TaskiqResult` as one JSON object; `get_result` may delete it after reading.
26
+
27
+ Several brokers may share one instance: `startup`/`shutdown` are refcounted and an injected
28
+ `client` is never closed. Expire old results with an S3 lifecycle rule on `prefix`; with
29
+ `delete_on_read=False` nothing here ever deletes them.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ bucket: str,
35
+ *,
36
+ prefix: str = "taskiq-results",
37
+ delete_on_read: bool = False,
38
+ declare: bool = False,
39
+ client: S3Client | None = None,
40
+ session: AioSession | None = None,
41
+ endpoint_url: str | None = None,
42
+ region_name: str | None = None,
43
+ aws_access_key_id: str | None = None,
44
+ aws_secret_access_key: str | None = None,
45
+ aws_session_token: str | None = None,
46
+ client_config: AioConfig | None = None,
47
+ ) -> None:
48
+ """Keep settings only; the S3 client is created in `startup` (no I/O here)."""
49
+ self.prefix = prefix
50
+ self.delete_on_read = delete_on_read
51
+ self.s3 = S3Connection(
52
+ bucket,
53
+ client=client,
54
+ session=session,
55
+ endpoint_url=endpoint_url,
56
+ region_name=region_name,
57
+ aws_access_key_id=aws_access_key_id,
58
+ aws_secret_access_key=aws_secret_access_key,
59
+ aws_session_token=aws_session_token,
60
+ client_config=client_config,
61
+ declare=declare,
62
+ )
63
+
64
+ async def startup(self) -> None:
65
+ """Create the S3 client and check (or declare) the bucket."""
66
+ await self.s3.startup()
67
+
68
+ async def shutdown(self) -> None:
69
+ """Close the owned S3 client once its last user shuts down."""
70
+ await self.s3.shutdown()
71
+
72
+ async def set_result(self, task_id: str, result: TaskiqResult[Any]) -> None:
73
+ """Write the result as JSON (overwrites an earlier result of the same task)."""
74
+ await self.s3.client.put_object(
75
+ Bucket=self.s3.bucket,
76
+ Key=object_key(self.prefix, task_id),
77
+ Body=result.model_dump_json().encode("utf-8"),
78
+ ContentType="application/json",
79
+ )
80
+
81
+ async def is_result_ready(self, task_id: str) -> bool:
82
+ """True when a result object exists for `task_id` (one HEAD request)."""
83
+ try:
84
+ await self.s3.client.head_object(
85
+ Bucket=self.s3.bucket, Key=object_key(self.prefix, task_id)
86
+ )
87
+ except ClientError as exc:
88
+ if is_missing(exc):
89
+ return False
90
+ raise
91
+ return True
92
+
93
+ async def get_result(self, task_id: str, with_logs: bool = False) -> TaskiqResult[Any]:
94
+ """Read one result (then delete it when `delete_on_read`); `ResultGetError` if absent.
95
+
96
+ Read-then-delete is two requests, so two concurrent readers may both get the result.
97
+ """
98
+ key = object_key(self.prefix, task_id)
99
+ raw = await self.s3.get_bytes(key)
100
+ if raw is None:
101
+ raise ResultGetError
102
+ result = TaskiqResult[Any].model_validate_json(raw)
103
+ if self.delete_on_read:
104
+ await self.s3.client.delete_object(Bucket=self.s3.bucket, Key=key)
105
+ return result
@@ -0,0 +1,173 @@
1
+ """Canary router: picks `sqs` or `direct` per call from live, audited routing config (§15.1).
2
+
3
+ `RoutingConfig` lives as JSON at `KeySpace.routing` and is reloaded every 30 s, so an operator
4
+ can promote, roll back or kill the queue path with `llm-queue routing ...` and no restart.
5
+ `LLMQueue` calls `CanaryRouter.route` on every request; it never does I/O.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import random
13
+ from collections.abc import Callable
14
+ from typing import TYPE_CHECKING, Annotated, Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from taskiq_llm_queue._logging import get_logger
19
+ from taskiq_llm_queue.config import DEFAULT_ID_HASH_SALT, Lane
20
+ from taskiq_llm_queue.envelope import keyed_digest
21
+ from taskiq_llm_queue.metrics_names import CONFIG_INVALID_TOTAL
22
+ from taskiq_llm_queue.reliability.config_store import LiveConfigStore
23
+
24
+ if TYPE_CHECKING:
25
+ import redis.asyncio
26
+
27
+ from taskiq_llm_queue.keys import KeySpace
28
+ from taskiq_llm_queue.metrics import MetricsSink
29
+
30
+ logger = get_logger(__name__)
31
+
32
+ RoutePath = Literal["sqs", "direct"]
33
+ Pct = Annotated[int, Field(ge=0, le=100)]
34
+ DEFAULT_RELOAD_SECS = 30.0
35
+
36
+
37
+ class RoutingConfig(BaseModel):
38
+ """Live canary config: kill switch, sqs percentage per model alias and lane, allowlist.
39
+
40
+ A model or lane that is not listed routes `direct` (0 %), so the empty default sends
41
+ everything direct. `allow_users` always go `sqs` unless the kill switch is on.
42
+ """
43
+
44
+ model_config = ConfigDict(frozen=True, extra="forbid")
45
+
46
+ kill_switch: bool = False
47
+ models: dict[str, dict[Lane, Pct]] = Field(default_factory=dict)
48
+ allow_users: frozenset[str] = frozenset()
49
+
50
+ def pct(self, model: str, lane: Lane) -> int:
51
+ """Share of calls (0-100) for `model` (alias or `alias@target`) and lane sent to sqs."""
52
+ return self.models.get(model.partition("@")[0], {}).get(lane, 0)
53
+
54
+ def with_pct(self, model: str, lane: Lane, pct: int) -> RoutingConfig:
55
+ """Copy with one model x lane percentage changed (promote / roll back)."""
56
+ models = {alias: dict(lanes) for alias, lanes in self.models.items()}
57
+ models.setdefault(model.partition("@")[0], {})[lane] = pct
58
+ return RoutingConfig.model_validate({**self.model_dump(), "models": models})
59
+
60
+ def with_kill(self, on: bool) -> RoutingConfig:
61
+ """Copy with the kill switch set (`on` routes every call `direct`)."""
62
+ return self.model_copy(update={"kill_switch": on})
63
+
64
+ def with_user(self, user_id: str, *, allowed: bool) -> RoutingConfig:
65
+ """Copy with `user_id` added to or removed from the allowlist."""
66
+ users = self.allow_users | {user_id} if allowed else self.allow_users - {user_id}
67
+ return self.model_copy(update={"allow_users": users})
68
+
69
+
70
+ def bucket(key: str, salt: str | bytes = DEFAULT_ID_HASH_SALT) -> int:
71
+ """Stable bucket 0-99 for a session or user id; equal on every pod sharing `salt`."""
72
+ return int.from_bytes(keyed_digest(key, salt), "big") % 100
73
+
74
+
75
+ def routing_store(
76
+ client: redis.asyncio.Redis, keys: KeySpace, default: RoutingConfig | None = None
77
+ ) -> LiveConfigStore[RoutingConfig]:
78
+ """Live store for routing at `KeySpace.routing`, audited to `KeySpace.audit`."""
79
+ return LiveConfigStore(
80
+ client, keys.routing, RoutingConfig, default or RoutingConfig(), audit_key=keys.audit
81
+ )
82
+
83
+
84
+ class CanaryRouter:
85
+ """`PathRouter` over a `LiveConfigStore[RoutingConfig]`, sticky per session (fallback user).
86
+
87
+ `route` reads the last good config only. `start` runs the reload loop as a task; an invalid
88
+ stored config keeps the last good one and emits `routing_config_invalid` (log + metric).
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ store: LiveConfigStore[RoutingConfig],
94
+ *,
95
+ metrics: MetricsSink | None = None,
96
+ reload_secs: float = DEFAULT_RELOAD_SECS,
97
+ rand: Callable[[], float] = random.random,
98
+ salt: str | bytes = DEFAULT_ID_HASH_SALT,
99
+ ) -> None:
100
+ """Keep the store and metrics sink; `rand` buckets calls that carry no id at all.
101
+
102
+ `salt` keys the sticky bucket hash (`QueueConfig.id_hash_salt`); all pods must share it.
103
+ """
104
+ self.store = store
105
+ self._salt = salt
106
+ self._metrics = metrics
107
+ self._reload_secs = reload_secs
108
+ self._rand = rand
109
+ self._seen_invalid = store.invalid_count
110
+ self._task: asyncio.Task[None] | None = None
111
+
112
+ @property
113
+ def config(self) -> RoutingConfig:
114
+ """The config `route` uses now (last good)."""
115
+ return self.store.current
116
+
117
+ def route(
118
+ self, model: str, lane: Lane, session_id: str | None, user_id: str | None
119
+ ) -> RoutePath:
120
+ """Decide the path of one call: kill switch, then allowlist, then sticky percentage."""
121
+ cfg = self.store.current
122
+ if cfg.kill_switch:
123
+ return "direct"
124
+ if user_id is not None and user_id in cfg.allow_users:
125
+ return "sqs"
126
+ pct = cfg.pct(model, lane)
127
+ if pct <= 0:
128
+ return "direct"
129
+ if pct >= 100:
130
+ return "sqs"
131
+ key = session_id or user_id
132
+ # No id at all: not sticky, but still honours the percentage on average.
133
+ point = bucket(key, self._salt) if key else int(self._rand() * 100)
134
+ return "sqs" if point < pct else "direct"
135
+
136
+ async def reload(self) -> RoutingConfig:
137
+ """Load the stored config once; report `routing_config_invalid` when it was rejected."""
138
+ cfg = await self.store.load()
139
+ invalid = self.store.invalid_count
140
+ if invalid > self._seen_invalid:
141
+ # The store warns once per bad value; this event is what the alarm (§13) keys on.
142
+ logger.error("routing_config_invalid", key=self.store.key, invalid_count=invalid)
143
+ if self._metrics is not None:
144
+ self._metrics.incr(
145
+ CONFIG_INVALID_TOTAL, invalid - self._seen_invalid, {"kind": "routing"}
146
+ )
147
+ self._seen_invalid = invalid
148
+ return cfg
149
+
150
+ async def run(self) -> None:
151
+ """Reload every `reload_secs` until cancelled; a failed reload never ends the loop."""
152
+ while True:
153
+ try:
154
+ await self.reload()
155
+ except Exception: # the loop must survive anything; route() keeps last good
156
+ logger.exception("routing_reload_failed")
157
+ await asyncio.sleep(self._reload_secs)
158
+
159
+ def start(self) -> asyncio.Task[None]:
160
+ """Start the reload loop on the running event loop (idempotent)."""
161
+ if self._task is None or self._task.done():
162
+ self._task = asyncio.get_running_loop().create_task(
163
+ self.run(), name="llmq-routing-reload"
164
+ )
165
+ return self._task
166
+
167
+ async def aclose(self) -> None:
168
+ """Stop the reload loop; safe to call twice."""
169
+ task, self._task = self._task, None
170
+ if task is not None:
171
+ task.cancel()
172
+ with contextlib.suppress(asyncio.CancelledError):
173
+ await task
@@ -0,0 +1,90 @@
1
+ """Extension points of `LLMQueue`: path routing, SQS/Valkey failover and observability hooks.
2
+
3
+ `PathRouter` (canary routing) and `FailoverPolicy` are implemented in `routing.py` /
4
+ `failover.py`; `QueueHooks` is subclassed by the metrics layer. This module has no heavy
5
+ imports, so implementations can depend on it freely.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
12
+
13
+ from taskiq_llm_queue._logging import get_logger
14
+
15
+ if TYPE_CHECKING:
16
+ from taskiq_llm_queue.config import Lane
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ CallPath = Literal["sqs", "direct"]
21
+
22
+
23
+ @runtime_checkable
24
+ class PathRouter(Protocol):
25
+ """Chooses how one call runs: through its model's SQS queue or `direct` in this process.
26
+
27
+ Without a router `LLMQueue` uses `sqs` when the model has queues, else `direct`.
28
+ """
29
+
30
+ def route(
31
+ self, model: str, lane: Lane, session_id: str | None, user_id: str | None
32
+ ) -> CallPath:
33
+ """Path for one call of model alias `model` in `lane` (sticky per session)."""
34
+ ...
35
+
36
+
37
+ @runtime_checkable
38
+ class FailoverPolicy(Protocol):
39
+ """Sender-side failover state: SQS outages per model and a pod-wide Valkey outage.
40
+
41
+ `LLMQueue` reports failures here and asks `path_override` / `redis_down` before each call;
42
+ implementations must never block or raise.
43
+ """
44
+
45
+ def path_override(self, model: str) -> Literal["direct"] | None:
46
+ """`direct` while `model` is failed over from SQS, else None (use the router)."""
47
+ ...
48
+
49
+ def report_sqs_failure(self, model: str, exc: BaseException) -> None:
50
+ """An SQS send for `model` failed; may start a failover window."""
51
+ ...
52
+
53
+ def report_redis_failure(self, exc: BaseException) -> None:
54
+ """A Valkey call failed; may switch the pod to Valkey-down mode (DS18)."""
55
+ ...
56
+
57
+ def redis_down(self) -> bool:
58
+ """True while the pod treats Valkey as unreachable."""
59
+ ...
60
+
61
+
62
+ class QueueHooks:
63
+ """No-op callbacks for client and worker events; the metrics layer overrides what it needs.
64
+
65
+ Every hook is called through `safe_hook`, so a failing hook never breaks a call. Labels are
66
+ low-cardinality only (model alias, target, lane, path, kind, step).
67
+ """
68
+
69
+ def on_call(self, model: str, lane: str, path: str, ok: bool, secs: float) -> None:
70
+ """One client call finished (`ok` False for errors and timeouts)."""
71
+
72
+ def on_attempt(self, model: str, target: str, kind: str, secs: float) -> None:
73
+ """One provider attempt finished; `kind` is `ok` or an `ErrorKind` value."""
74
+
75
+ def on_fallback(self, model: str, step: str) -> None:
76
+ """A call was served by fallback step `step` (`alias@target`) instead of the first."""
77
+
78
+ def on_queue_wait_ms(self, model: str, lane: str, wait_ms: int) -> None:
79
+ """A worker started a task `wait_ms` after it was enqueued."""
80
+
81
+ def on_sync_fallback(self, model: str) -> None:
82
+ """A sync call ran `direct` under the shared limiter (`llmq_sync_fallback_total`)."""
83
+
84
+
85
+ def safe_hook(hook: Callable[..., Any], *args: Any) -> None:
86
+ """Run one hook, logging (never raising) whatever it throws."""
87
+ try:
88
+ hook(*args)
89
+ except Exception:
90
+ logger.exception("routing_hook_failed", hook=getattr(hook, "__name__", repr(hook)))