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,592 @@
1
+ """Typed configuration: models and their targets, queues, Redis/SQS endpoints, timeouts.
2
+
3
+ `QueueConfig` is the single object an app builds (directly or with `from_env`) and passes to
4
+ `LLMQueue`. Validation runs once at construction, so bad config fails at boot, not mid-request.
5
+ `create_redis` / `create_sync_redis` build the package's Redis/Valkey clients from it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import threading
13
+ import warnings
14
+ from collections.abc import Callable, Mapping
15
+ from enum import StrEnum
16
+ from typing import TYPE_CHECKING, Any, Literal
17
+
18
+ import orjson
19
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
20
+
21
+ from taskiq_llm_queue._logging import get_logger
22
+ from taskiq_llm_queue.errors import ConfigError
23
+
24
+ if TYPE_CHECKING:
25
+ import redis
26
+ import redis.asyncio
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ # Built-in provider names; custom providers (QueueConfig.providers / entry points) add their own.
31
+ BUILTIN_PROVIDERS = frozenset({"bedrock", "openai"})
32
+ ProviderKind = str
33
+ ENV_PREFIX = "LLMQ_"
34
+ _ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
35
+ # Key of the BLAKE2b hash behind hashed ids and canary buckets. Public and fixed, so only good
36
+ # for local use: set your own `LLMQ_ID_HASH_SALT` in production (all pods must share it).
37
+ DEFAULT_ID_HASH_SALT = "taskiq-llm-queue/id-hash/v1"
38
+ _MAX_SALT_BYTES = 64 # BLAKE2b key limit
39
+
40
+
41
+ class Lane(StrEnum):
42
+ """Request priority: live chat, background work, bulk ingestion (highest first)."""
43
+
44
+ RT = "rt"
45
+ BG = "bg"
46
+ BULK = "bulk"
47
+
48
+
49
+ class Target(BaseModel):
50
+ """One place a model can be served from: a region, a cross-region profile or a URL."""
51
+
52
+ model_config = ConfigDict(frozen=True, extra="forbid")
53
+
54
+ region: str | None = None
55
+ profile_id: str | None = None
56
+ base_url: str | None = None
57
+
58
+
59
+ class ModelConfig(BaseModel):
60
+ """One model alias: provider, provider model id, named targets and its queues.
61
+
62
+ `queues` maps `rt` / `default` / optional `bulk` to SQS queue names or URLs. The first entry
63
+ in `targets` is the primary target.
64
+ """
65
+
66
+ model_config = ConfigDict(frozen=True, extra="forbid")
67
+
68
+ provider: ProviderKind
69
+ model_id: str
70
+ targets: dict[str, Target] = Field(default_factory=lambda: {"primary": Target()})
71
+ queues: dict[Literal["rt", "default", "bulk"], str] = Field(default_factory=dict)
72
+ limiter_initial: int = Field(default=20, ge=1)
73
+ api_key_env: str | None = None
74
+
75
+ @field_validator("targets")
76
+ @classmethod
77
+ def _targets_not_empty(cls, value: dict[str, Target]) -> dict[str, Target]:
78
+ if not value:
79
+ raise ValueError("a model needs at least one target")
80
+ return value
81
+
82
+
83
+ class LimiterTuning(BaseModel):
84
+ """Adaptive limiter tuning (`LLMQ_AC_*`), mapped onto `reliability.LimiterSettings`.
85
+
86
+ `initial` overrides a model's `limiter_initial` by alias (`LLMQ_AC_INITIAL`, JSON).
87
+ """
88
+
89
+ model_config = ConfigDict(frozen=True, extra="forbid")
90
+
91
+ initial: dict[str, int] = Field(default_factory=dict)
92
+ min_limit: int = Field(default=2, ge=1)
93
+ max_limit: int = Field(default=2000, ge=1)
94
+ ramp_window: int = Field(default=50, ge=1)
95
+ increase_every: int = Field(default=20, ge=1)
96
+ decrease_factor: float = Field(default=0.7, gt=0, lt=1)
97
+ pause_secs: float = Field(default=2.0, ge=0)
98
+ bg_share: float = Field(default=0.8, gt=0, le=1)
99
+ utilisation: float = Field(default=0.9, gt=0, le=1)
100
+ ramp_cap: int | None = Field(default=None, ge=1)
101
+ # Renewed every pod heartbeat (10 s); a killed pod's slots return within it (S35b: 240 s
102
+ # left a killed worker's leases blocking most of the limit for 4 minutes).
103
+ lease_ttl_secs: float = Field(default=60.0, gt=0)
104
+
105
+ @model_validator(mode="after")
106
+ def _min_le_max(self) -> LimiterTuning:
107
+ if self.min_limit > self.max_limit:
108
+ raise ValueError("limiter.min_limit must be <= limiter.max_limit")
109
+ return self
110
+
111
+
112
+ class BreakerTuning(BaseModel):
113
+ """Circuit breaker tuning (`LLMQ_CB_*`), mapped onto `reliability.BreakerSettings`."""
114
+
115
+ model_config = ConfigDict(frozen=True, extra="forbid")
116
+
117
+ threshold: int = Field(default=5, ge=1)
118
+ window_secs: float = Field(default=30.0, gt=0)
119
+ cooldown_secs: float = Field(default=15.0, gt=0)
120
+
121
+
122
+ def _lane_map(default: Mapping[Lane, Any]) -> Any:
123
+ """Field factory for per-lane settings (a fresh dict per instance)."""
124
+ return Field(default_factory=lambda: dict(default))
125
+
126
+
127
+ class QueueConfig(BaseModel):
128
+ """Everything `LLMQueue` needs; immutable after construction.
129
+
130
+ Timeouts are per lane; TTLs follow one rule (state keys outlive the longest lane
131
+ deadline plus worst-case execution; results only until the caller reads them).
132
+ """
133
+
134
+ model_config = ConfigDict(frozen=True, extra="forbid")
135
+
136
+ key_prefix: str = "llmq:local"
137
+ # Redis/Valkey: `redis_url` (redis:// or rediss://) wins over host/port/ssl when set; its
138
+ # credentials win over `redis_username` / `redis_password`.
139
+ redis_url: SecretStr | None = None
140
+ redis_host: str = "localhost"
141
+ redis_port: int = 6379
142
+ redis_username: str | None = None
143
+ redis_password: SecretStr | None = None
144
+ redis_ssl: bool = False
145
+ redis_max_connections: int = Field(default=32, ge=1)
146
+ redis_socket_timeout_secs: float = Field(default=5.0, gt=0)
147
+ redis_socket_connect_timeout_secs: float = Field(default=2.0, gt=0)
148
+ redis_health_check_interval_secs: int = Field(default=30, ge=0)
149
+ redis_retry_attempts: int = Field(default=3, ge=0, le=10)
150
+ redis_client_name: str | None = "taskiq-llm-queue"
151
+ sqs_endpoint_url: str | None = None
152
+ bedrock_endpoint_url: str | None = None
153
+ # None lets the AWS chain decide (AWS_REGION, profile, IRSA / Pod Identity).
154
+ aws_region: str | None = None
155
+ models: dict[str, ModelConfig] = Field(default_factory=dict)
156
+ allowed_providers: frozenset[ProviderKind] = BUILTIN_PROVIDERS
157
+ denied_hosts: tuple[str, ...] = ()
158
+ providers: dict[str, str] = Field(default_factory=dict)
159
+ timeout_secs: dict[Lane, float] = Field(
160
+ default_factory=lambda: {Lane.RT: 90.0, Lane.BG: 300.0, Lane.BULK: 900.0}
161
+ )
162
+ state_ttl_secs: int = Field(default=1800, ge=60)
163
+ result_ttl_secs: int = Field(default=300, ge=10)
164
+ inline_max_bytes: int = Field(default=200_000, ge=1024, le=1_000_000)
165
+ hash_ids: bool = True
166
+ id_hash_salt: SecretStr = SecretStr(DEFAULT_ID_HASH_SALT)
167
+ strict_async: bool = False
168
+ # --- runtime tuning (all optional; defaults are the recommended production starts)
169
+ limiter: LimiterTuning = Field(default_factory=LimiterTuning)
170
+ breaker: BreakerTuning = Field(default_factory=BreakerTuning)
171
+ max_failures: int = Field(default=3, ge=1)
172
+ local_limit_cap: int = Field(default=10, ge=1)
173
+ cfg_reload_secs: float = Field(default=30.0, gt=0)
174
+ # Receiver `max_async_tasks` per model alias (`jobs` for the job broker); unset -> default.
175
+ worker_slots: dict[str, int] = Field(default_factory=dict)
176
+ drain_secs: float = Field(default=45.0, ge=0)
177
+ result_dispatch_ms: dict[Lane, int] = _lane_map({Lane.RT: 100, Lane.BG: 500, Lane.BULK: 2000})
178
+ embed_timeout_secs: dict[Lane, float] = _lane_map(
179
+ {Lane.RT: 10.0, Lane.BG: 60.0, Lane.BULK: 300.0}
180
+ )
181
+ # Startup default of the live fallback chains (`reliability.FallbackConfig` JSON shape).
182
+ fallback_default: dict[str, Any] = Field(default_factory=dict)
183
+ routing_enabled: bool = False # build a CanaryRouter on the live routing config
184
+ routing_default: dict[str, Any] = Field(default_factory=dict)
185
+ job_queues: dict[str, str] = Field(default_factory=dict)
186
+ retry_attempts: int = Field(default=3, ge=1, le=10)
187
+ retry_base_secs: float = Field(default=1.0, ge=0)
188
+ throttle_retry_attempts: int = Field(
189
+ default=6,
190
+ ge=1,
191
+ le=20,
192
+ description="Attempts per fallback step once the provider throttled it (instead of "
193
+ "`retry_attempts` when larger), each after a backoff that holds no limiter slot. "
194
+ "At S35b's 30 % random 429 rate a step fails 0.3^3 = 2.7 % with 3 tries, 0.07 % with 6.",
195
+ )
196
+ sqs_wait_time_secs: int = Field(default=20, ge=0, le=20)
197
+ sqs_failover_secs: float = Field(default=300.0, gt=0)
198
+ attempt_timeout_secs: float = Field(
199
+ default=180.0,
200
+ gt=0,
201
+ description="Longest one provider attempt may run (capped by the call's remaining "
202
+ "deadline); on expiry the worker tries the next fallback target. Unverified default: "
203
+ "long enough for an ~8k-token completion.",
204
+ )
205
+ provider_read_timeout_secs: float = Field(
206
+ default=300.0,
207
+ gt=0,
208
+ description="Socket read timeout of the built-in providers (botocore `read_timeout`, "
209
+ "httpx read for OpenAI-compatible servers).",
210
+ )
211
+ provider_connect_timeout_secs: float = Field(
212
+ default=10.0,
213
+ gt=0,
214
+ description="Connect timeout of the built-in providers (botocore `connect_timeout`, "
215
+ "httpx connect for OpenAI-compatible servers).",
216
+ )
217
+ sqs_send_timeout_secs: float = Field(
218
+ default=5.0,
219
+ gt=0,
220
+ description="Upper bound on one SQS send before the call fails over to direct; the "
221
+ "send also gets at most half the call's remaining budget. A send to an unreachable "
222
+ "SQS otherwise hangs on a dead pooled connection until the 60 s read timeout.",
223
+ )
224
+ valkey_wait_failover_secs: float = Field(
225
+ default=10.0,
226
+ gt=0,
227
+ description="How long result reads may fail before callers waiting on a queued call "
228
+ "run it direct under the local cap instead of waiting to their deadline. Longer than a "
229
+ "Valkey restart blip, so in-flight worker results are not run twice.",
230
+ )
231
+
232
+ @field_validator("id_hash_salt")
233
+ @classmethod
234
+ def _salt_fits(cls, value: SecretStr) -> SecretStr:
235
+ size = len(value.get_secret_value().encode("utf-8"))
236
+ if not 1 <= size <= _MAX_SALT_BYTES:
237
+ raise ValueError(f"id_hash_salt must be 1-{_MAX_SALT_BYTES} bytes, got {size}")
238
+ return value
239
+
240
+ @field_validator("models")
241
+ @classmethod
242
+ def _aliases_valid(cls, value: dict[str, ModelConfig]) -> dict[str, ModelConfig]:
243
+ bad = [alias for alias in value if not _ALIAS_RE.match(alias)]
244
+ if bad:
245
+ raise ValueError(f"invalid model aliases (lowercase, no '@'): {bad}")
246
+ return value
247
+
248
+ @model_validator(mode="after")
249
+ def _providers_allowed(self) -> QueueConfig:
250
+ blocked = {
251
+ a: m.provider
252
+ for a, m in self.models.items()
253
+ if m.provider not in self.allowed_providers
254
+ }
255
+ if blocked:
256
+ raise ValueError(f"models use providers outside allowed_providers: {blocked}")
257
+ if self.fallback_default:
258
+ from taskiq_llm_queue.reliability.fallback import FallbackConfig
259
+
260
+ FallbackConfig.model_validate(self.fallback_default) # fail at boot, not per call
261
+ bad_slots = {k: v for k, v in self.worker_slots.items() if v < 1}
262
+ if bad_slots:
263
+ raise ValueError(f"worker_slots must be >= 1: {bad_slots}")
264
+ return self
265
+
266
+ def resolve(self, model: str) -> tuple[str, ModelConfig, str]:
267
+ """Split `alias[@target]` and return (alias, model config, target name).
268
+
269
+ Raises `ConfigError` for an unknown alias or target, so typos surface on first use.
270
+ """
271
+ alias, _, target = model.partition("@")
272
+ cfg = self.models.get(alias)
273
+ if cfg is None:
274
+ raise ConfigError(f"unknown model alias {alias!r}; known: {sorted(self.models)}")
275
+ target = target or next(iter(cfg.targets))
276
+ if target not in cfg.targets:
277
+ raise ConfigError(
278
+ f"unknown target {target!r} for {alias!r}; known: {sorted(cfg.targets)}"
279
+ )
280
+ return alias, cfg, target
281
+
282
+ @property
283
+ def id_hash_key(self) -> bytes:
284
+ """`id_hash_salt` as the BLAKE2b key for hashed ids and canary buckets."""
285
+ return self.id_hash_salt.get_secret_value().encode("utf-8")
286
+
287
+ @classmethod
288
+ def from_env(cls, environ: Mapping[str, str] | None = None) -> QueueConfig:
289
+ """Build a config from `LLMQ_*` variables; `LLMQ_MODELS` holds the models as JSON.
290
+
291
+ Every bad value is collected and reported in one `ConfigError`.
292
+ """
293
+ env = os.environ if environ is None else environ
294
+
295
+ def get(name: str) -> str | None:
296
+ return env_value(env, name)
297
+
298
+ raw: dict[str, object] = {}
299
+ errors: list[str] = []
300
+ for name, key, conv in (
301
+ ("KEY_PREFIX", "key_prefix", str),
302
+ ("REDIS_URL", "redis_url", str),
303
+ ("REDIS_HOST", "redis_host", str),
304
+ ("REDIS_PORT", "redis_port", int),
305
+ ("REDIS_USERNAME", "redis_username", str),
306
+ ("REDIS_PASSWORD", "redis_password", str),
307
+ ("REDIS_SSL", "redis_ssl", _to_bool),
308
+ ("REDIS_MAX_CONNECTIONS", "redis_max_connections", int),
309
+ ("REDIS_SOCKET_TIMEOUT_SECS", "redis_socket_timeout_secs", float),
310
+ ("REDIS_SOCKET_CONNECT_TIMEOUT_SECS", "redis_socket_connect_timeout_secs", float),
311
+ ("REDIS_HEALTH_CHECK_INTERVAL_SECS", "redis_health_check_interval_secs", int),
312
+ ("REDIS_RETRY_ATTEMPTS", "redis_retry_attempts", int),
313
+ ("REDIS_CLIENT_NAME", "redis_client_name", str),
314
+ ("SQS_ENDPOINT_URL", "sqs_endpoint_url", str),
315
+ ("BEDROCK_ENDPOINT_URL", "bedrock_endpoint_url", str),
316
+ ("AWS_REGION", "aws_region", str),
317
+ ("STATE_TTL_SECS", "state_ttl_secs", int),
318
+ ("RESULT_TTL_SECS", "result_ttl_secs", int),
319
+ ("INLINE_MAX_BYTES", "inline_max_bytes", int),
320
+ ("HASH_IDS", "hash_ids", _to_bool),
321
+ ("ID_HASH_SALT", "id_hash_salt", str),
322
+ ("STRICT_ASYNC", "strict_async", _to_bool),
323
+ ("ROUTING_ENABLED", "routing_enabled", _to_bool),
324
+ ("MAX_FAILURES", "max_failures", int),
325
+ ("LOCAL_LIMIT_CAP", "local_limit_cap", int),
326
+ ("CFG_RELOAD_SECS", "cfg_reload_secs", float),
327
+ ("CONSUMER_DRAIN_SECS", "drain_secs", float),
328
+ ("RETRY_ATTEMPTS", "retry_attempts", int),
329
+ ("RETRY_BASE_SECS", "retry_base_secs", float),
330
+ ("THROTTLE_RETRY_ATTEMPTS", "throttle_retry_attempts", int),
331
+ ("SQS_WAIT_TIME_SECS", "sqs_wait_time_secs", int),
332
+ ("SQS_FAILOVER_SECS", "sqs_failover_secs", float),
333
+ ("ATTEMPT_TIMEOUT_SECS", "attempt_timeout_secs", float),
334
+ ("PROVIDER_READ_TIMEOUT_SECS", "provider_read_timeout_secs", float),
335
+ ("PROVIDER_CONNECT_TIMEOUT_SECS", "provider_connect_timeout_secs", float),
336
+ ("SQS_SEND_TIMEOUT_SECS", "sqs_send_timeout_secs", float),
337
+ ("VALKEY_WAIT_FAILOVER_SECS", "valkey_wait_failover_secs", float),
338
+ ("WORKER_SLOTS", "worker_slots", _int_map),
339
+ ):
340
+ value = get(name)
341
+ if value is None:
342
+ continue
343
+ try:
344
+ raw[key] = conv(value)
345
+ except ValueError as exc:
346
+ errors.append(f"{ENV_PREFIX}{name}: {exc}")
347
+ for name, key in (
348
+ ("MODELS", "models"),
349
+ ("PROVIDERS", "providers"),
350
+ ("FALLBACK_CHAINS", "fallback_default"),
351
+ ("ROUTING_DEFAULT", "routing_default"),
352
+ ("JOB_QUEUES", "job_queues"),
353
+ ):
354
+ value = get(name)
355
+ if value:
356
+ try:
357
+ raw[key] = orjson.loads(value)
358
+ except orjson.JSONDecodeError as exc:
359
+ errors.append(f"{ENV_PREFIX}{name}: invalid JSON ({exc})")
360
+ _lanes_from_env(get, raw, errors)
361
+ _tuning_from_env(get, raw, errors)
362
+ if (allowed := get("ALLOWED_PROVIDERS")) is not None:
363
+ raw["allowed_providers"] = frozenset(p.strip() for p in allowed.split(",") if p.strip())
364
+ if (denied := get("DENIED_HOSTS")) is not None:
365
+ raw["denied_hosts"] = tuple(h.strip() for h in denied.split(",") if h.strip())
366
+ if errors:
367
+ raise ConfigError("; ".join(errors))
368
+ try:
369
+ return cls.model_validate(raw)
370
+ except ValueError as exc:
371
+ raise ConfigError(str(exc)) from exc
372
+
373
+
374
+ _Getter = Callable[[str], "str | None"]
375
+
376
+ # (env suffix, LimiterTuning field, converter) for `LLMQ_AC_*`; `CB_*` likewise for the breaker.
377
+ _LIMITER_ENV: tuple[tuple[str, str, Callable[[str], Any]], ...] = (
378
+ ("AC_MIN", "min_limit", int),
379
+ ("AC_MAX", "max_limit", int),
380
+ ("AC_RAMP_WINDOW", "ramp_window", int),
381
+ ("AC_INCREASE_EVERY", "increase_every", int),
382
+ ("AC_DECREASE_FACTOR", "decrease_factor", float),
383
+ ("AC_PAUSE_SECS", "pause_secs", float),
384
+ ("AC_BG_SHARE", "bg_share", float),
385
+ ("AC_RAMP_CAP", "ramp_cap", int),
386
+ ("AC_LEASE_TTL_SECS", "lease_ttl_secs", float),
387
+ )
388
+ _BREAKER_ENV: tuple[tuple[str, str, Callable[[str], Any]], ...] = (
389
+ ("CB_THRESHOLD", "threshold", int),
390
+ ("CB_WINDOW_SECS", "window_secs", float),
391
+ ("CB_COOLDOWN_SECS", "cooldown_secs", float),
392
+ )
393
+ # Deprecated `LLMQ_*` spellings (current name -> old name), read only when the new one is unset.
394
+ DEPRECATED_ENV: Mapping[str, str] = {
395
+ "CFG_RELOAD_SECS": "CFG_RELOAD_S",
396
+ "CONSUMER_DRAIN_SECS": "CONSUMER_DRAIN_S",
397
+ "RETRY_BASE_SECS": "RETRY_BASE_S",
398
+ "SQS_WAIT_TIME_SECS": "SQS_WAIT_TIME_S",
399
+ "SQS_FAILOVER_SECS": "SQS_FAILOVER_S",
400
+ "AC_PAUSE_SECS": "AC_PAUSE_S",
401
+ "AC_LEASE_TTL_SECS": "AC_LEASE_TTL_S",
402
+ "CB_WINDOW_SECS": "CB_WINDOW_S",
403
+ "CB_COOLDOWN_SECS": "CB_COOLDOWN_S",
404
+ **{f"TIMEOUT_{lane.value.upper()}_SECS": f"TIMEOUT_{lane.value.upper()}" for lane in Lane},
405
+ **{
406
+ f"TIMEOUT_EMBED_{lane.value.upper()}_SECS": f"TIMEOUT_EMBED_{lane.value.upper()}"
407
+ for lane in Lane
408
+ },
409
+ }
410
+ _warned_env: set[str] = set()
411
+ _warned_lock = threading.Lock()
412
+
413
+
414
+ def env_value(env: Mapping[str, str], name: str) -> str | None:
415
+ """`LLMQ_<name>`, else its deprecated spelling (warned once per process), else None."""
416
+ value = env.get(ENV_PREFIX + name)
417
+ old = DEPRECATED_ENV.get(name)
418
+ if value is not None or old is None:
419
+ return value
420
+ value = env.get(ENV_PREFIX + old)
421
+ if value is not None:
422
+ with _warned_lock:
423
+ first = old not in _warned_env
424
+ _warned_env.add(old)
425
+ if first:
426
+ message = f"{ENV_PREFIX}{old} is deprecated; use {ENV_PREFIX}{name}"
427
+ warnings.warn(message, DeprecationWarning, stacklevel=3)
428
+ logger.warning("deprecated_env_name", old=ENV_PREFIX + old, new=ENV_PREFIX + name)
429
+ return value
430
+
431
+
432
+ def _int_map(value: str) -> dict[str, int]:
433
+ """Parse `a:1,b:2` (the `LLMQ_WORKER_SLOTS` / `LLMQ_RESULT_DISPATCH_MS` shape)."""
434
+ out: dict[str, int] = {}
435
+ for part in value.split(","):
436
+ if not part.strip():
437
+ continue
438
+ name, sep, number = part.partition(":")
439
+ if not sep or not name.strip():
440
+ raise ValueError(f"expected name:number pairs, got {part!r}")
441
+ out[name.strip()] = int(number)
442
+ return out
443
+
444
+
445
+ def _lanes_from_env(get: _Getter, raw: dict[str, object], errors: list[str]) -> None:
446
+ """`LLMQ_TIMEOUT_<LANE>_SECS`, `LLMQ_TIMEOUT_EMBED_<LANE>_SECS`, `LLMQ_RESULT_DISPATCH_MS`."""
447
+ for prefix, key in (("TIMEOUT_", "timeout_secs"), ("TIMEOUT_EMBED_", "embed_timeout_secs")):
448
+ lanes: dict[Lane, float] = {}
449
+ for lane in Lane:
450
+ name = f"{prefix}{lane.value.upper()}_SECS"
451
+ value = get(name)
452
+ if value is None:
453
+ continue
454
+ try:
455
+ lanes[lane] = float(value)
456
+ except ValueError as exc:
457
+ errors.append(f"{ENV_PREFIX}{name}: {exc}")
458
+ if lanes:
459
+ default = QueueConfig.model_fields[key].get_default(call_default_factory=True)
460
+ raw[key] = {**default, **lanes}
461
+ if (value := get("RESULT_DISPATCH_MS")) is not None:
462
+ try:
463
+ raw["result_dispatch_ms"] = {Lane(k): v for k, v in _int_map(value).items()}
464
+ except ValueError as exc:
465
+ errors.append(f"{ENV_PREFIX}RESULT_DISPATCH_MS: {exc}")
466
+
467
+
468
+ def _tuning_from_env(get: _Getter, raw: dict[str, object], errors: list[str]) -> None:
469
+ """Nested `limiter` (`LLMQ_AC_*`, `LLMQ_AC_INITIAL` JSON) and `breaker` (`LLMQ_CB_*`)."""
470
+ for field, table in (("limiter", _LIMITER_ENV), ("breaker", _BREAKER_ENV)):
471
+ values: dict[str, Any] = {}
472
+ for name, key, conv in table:
473
+ value = get(name)
474
+ if value is None:
475
+ continue
476
+ try:
477
+ values[key] = conv(value)
478
+ except ValueError as exc:
479
+ errors.append(f"{ENV_PREFIX}{name}: {exc}")
480
+ if field == "limiter" and (initial := get("AC_INITIAL")):
481
+ try:
482
+ values["initial"] = orjson.loads(initial)
483
+ except orjson.JSONDecodeError as exc:
484
+ errors.append(f"{ENV_PREFIX}AC_INITIAL: invalid JSON ({exc})")
485
+ if values:
486
+ raw[field] = values
487
+
488
+
489
+ def _to_bool(value: str) -> bool:
490
+ """Parse the usual env-var spellings of a boolean; anything else is an error."""
491
+ lowered = value.strip().lower()
492
+ if lowered in {"1", "true", "yes", "on"}:
493
+ return True
494
+ if lowered in {"0", "false", "no", "off"}:
495
+ return False
496
+ raise ValueError(f"not a boolean: {value!r}")
497
+
498
+
499
+ def _redis_kwargs(config: QueueConfig, retry: Any, client_name: str | None) -> dict[str, Any]:
500
+ """Pool settings shared by the async and sync clients (everything but the address)."""
501
+ from redis.exceptions import ConnectionError as RedisConnectionError
502
+
503
+ password = config.redis_password
504
+ return {
505
+ "username": config.redis_username,
506
+ "password": password.get_secret_value() if password is not None else None,
507
+ "max_connections": redis_pool_size(config),
508
+ "socket_timeout": config.redis_socket_timeout_secs,
509
+ "socket_connect_timeout": config.redis_socket_connect_timeout_secs,
510
+ "health_check_interval": config.redis_health_check_interval_secs,
511
+ "retry": retry,
512
+ "retry_on_error": [RedisConnectionError],
513
+ "client_name": client_name or config.redis_client_name,
514
+ "timeout": _POOL_WAIT_SECS,
515
+ }
516
+
517
+
518
+ _POOL_WAIT_SECS = 5.0 # BlockingConnectionPool: wait this long for a free connection
519
+ _POOL_SPARE = 16 # connections beyond the worker slots: heartbeat, dispatcher, callers
520
+
521
+
522
+ def redis_pool_size(config: QueueConfig) -> int:
523
+ """Connection cap of the package's Valkey pools: `redis_max_connections`, raised to fit.
524
+
525
+ Every worker slot may hold a connection at once (claim, acquire poll, result store), so
526
+ with queued models the cap is at least their worker slots plus `_POOL_SPARE`. Bug: 32
527
+ connections under 154 slots waited out `_POOL_WAIT_SECS`, read as "Valkey down" and leaked
528
+ a limiter lease.
529
+ """
530
+ from taskiq_llm_queue.lifecycle import DEFAULT_JOB_SLOTS, DEFAULT_WORKER_SLOTS, JOBS_BROKER
531
+
532
+ brokers: dict[tuple[str, ...], int] = {} # aliases with the same queues share one broker
533
+ for alias, model in config.models.items():
534
+ if model.queues:
535
+ key = tuple(sorted(model.queues.values()))
536
+ brokers.setdefault(key, config.worker_slots.get(alias, DEFAULT_WORKER_SLOTS))
537
+ slots = sum(brokers.values())
538
+ if config.job_queues:
539
+ slots += config.worker_slots.get(JOBS_BROKER, DEFAULT_JOB_SLOTS)
540
+ return (
541
+ max(config.redis_max_connections, slots + _POOL_SPARE)
542
+ if slots
543
+ else config.redis_max_connections
544
+ )
545
+
546
+
547
+ def _backoff() -> Any:
548
+ from redis.backoff import ExponentialBackoff
549
+
550
+ return ExponentialBackoff(cap=1.0, base=0.1)
551
+
552
+
553
+ def create_redis(config: QueueConfig, *, client_name: str | None = None) -> redis.asyncio.Redis:
554
+ """The package's own async client: bounded blocking pool, socket timeouts and retries.
555
+
556
+ The one factory for every async Valkey client the package builds (no `decode_responses`).
557
+ Connection errors are retried `redis_retry_attempts` times with exponential backoff.
558
+ """
559
+ from redis.asyncio import BlockingConnectionPool, Redis
560
+ from redis.asyncio.connection import Connection, SSLConnection
561
+ from redis.asyncio.retry import Retry
562
+
563
+ kwargs = _redis_kwargs(config, Retry(_backoff(), config.redis_retry_attempts), client_name)
564
+ if config.redis_url is not None:
565
+ pool = BlockingConnectionPool.from_url(config.redis_url.get_secret_value(), **kwargs)
566
+ else:
567
+ pool = BlockingConnectionPool(
568
+ connection_class=SSLConnection if config.redis_ssl else Connection,
569
+ host=config.redis_host,
570
+ port=config.redis_port,
571
+ **kwargs,
572
+ )
573
+ return Redis(connection_pool=pool)
574
+
575
+
576
+ def create_sync_redis(config: QueueConfig, *, client_name: str | None = None) -> redis.Redis:
577
+ """Blocking twin of `create_redis` (same settings) for sync callers and tools."""
578
+ from redis import BlockingConnectionPool, Redis
579
+ from redis.connection import Connection, SSLConnection
580
+ from redis.retry import Retry
581
+
582
+ kwargs = _redis_kwargs(config, Retry(_backoff(), config.redis_retry_attempts), client_name)
583
+ if config.redis_url is not None:
584
+ pool = BlockingConnectionPool.from_url(config.redis_url.get_secret_value(), **kwargs)
585
+ else:
586
+ pool = BlockingConnectionPool( # type: ignore[no-untyped-call] # redis stub gap
587
+ connection_class=SSLConnection if config.redis_ssl else Connection,
588
+ host=config.redis_host,
589
+ port=config.redis_port,
590
+ **kwargs,
591
+ )
592
+ return Redis(connection_pool=pool)
@@ -0,0 +1,62 @@
1
+ """Per-request context (lane, session, user) carried through async tasks and threads.
2
+
3
+ Routers set it once with `request_context(...)`; queued adapters read it on every call to pick
4
+ the lane and the canary bucket. Raw threads must run under `contextvars.copy_context().run`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from contextvars import ContextVar
12
+ from dataclasses import dataclass, replace
13
+
14
+ from taskiq_llm_queue.config import Lane
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class RequestContext:
19
+ """What the current request is: its lane and the ids used for routing and tracing.
20
+
21
+ `defaulted` is True when no `request_context` was set, so callers can warn once about
22
+ calls that silently land in the background lane.
23
+ """
24
+
25
+ lane: Lane = Lane.BG
26
+ session_id: str | None = None
27
+ user_id: str | None = None
28
+ defaulted: bool = True
29
+
30
+
31
+ _CURRENT: ContextVar[RequestContext | None] = ContextVar("taskiq_llm_queue_context", default=None)
32
+
33
+
34
+ def current_context() -> RequestContext:
35
+ """The active context, or the default (`bg` lane, `defaulted=True`) when none is set."""
36
+ return _CURRENT.get() or RequestContext()
37
+
38
+
39
+ @contextmanager
40
+ def request_context(
41
+ *,
42
+ lane: Lane | str | None = None,
43
+ session_id: str | None = None,
44
+ user_id: str | None = None,
45
+ ) -> Iterator[RequestContext]:
46
+ """Set the lane and ids for everything run inside the block; unset fields are inherited.
47
+
48
+ Nesting is allowed, so a background job inside a request can switch only the lane.
49
+ """
50
+ base = current_context()
51
+ ctx = replace(
52
+ base,
53
+ lane=Lane(lane) if lane is not None else base.lane,
54
+ session_id=session_id if session_id is not None else base.session_id,
55
+ user_id=user_id if user_id is not None else base.user_id,
56
+ defaulted=False,
57
+ )
58
+ token = _CURRENT.set(ctx)
59
+ try:
60
+ yield ctx
61
+ finally:
62
+ _CURRENT.reset(token)