taskiq-llm-queue 0.1.0a1__tar.gz
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.
- taskiq_llm_queue-0.1.0a1/.gitignore +35 -0
- taskiq_llm_queue-0.1.0a1/CHANGELOG.md +85 -0
- taskiq_llm_queue-0.1.0a1/LICENSE +21 -0
- taskiq_llm_queue-0.1.0a1/NOTICE +15 -0
- taskiq_llm_queue-0.1.0a1/PKG-INFO +296 -0
- taskiq_llm_queue-0.1.0a1/README.md +247 -0
- taskiq_llm_queue-0.1.0a1/STABILITY.md +14 -0
- taskiq_llm_queue-0.1.0a1/docs/DESIGN.md +89 -0
- taskiq_llm_queue-0.1.0a1/docs/EXAMPLES.md +20 -0
- taskiq_llm_queue-0.1.0a1/docs/RELEASING.md +47 -0
- taskiq_llm_queue-0.1.0a1/docs/TESTING.md +293 -0
- taskiq_llm_queue-0.1.0a1/pyproject.toml +141 -0
- taskiq_llm_queue-0.1.0a1/samples/README.md +175 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/__init__.py +138 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/_logging.py +40 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/_version.py +5 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/__init__.py +86 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/acker.py +239 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/batching.py +188 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/broker.py +937 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/delivery.py +50 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/errors.py +178 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/events.py +51 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/filters.py +32 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/heartbeat.py +157 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/queue.py +141 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/s3_offload.py +346 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/scheduler.py +113 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/broker/serializer.py +32 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/cli.py +488 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/client.py +1409 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/config.py +592 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/context.py +62 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/core.py +832 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/envelope.py +136 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/errors.py +71 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/failover.py +286 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/http_transport.py +220 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/keys.py +87 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/langchain/__init__.py +64 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/langchain/_client.py +142 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/langchain/bedrock.py +291 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/langchain/openai.py +103 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/lifecycle.py +512 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/metrics/__init__.py +45 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/metrics/emf.py +100 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/metrics/prometheus.py +66 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/metrics_names.py +19 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/protocols.py +140 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/providers/__init__.py +62 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/providers/bedrock.py +188 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/providers/classify.py +251 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/providers/embeddings.py +87 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/providers/hosts.py +34 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/providers/openai_compat.py +142 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/providers/registry.py +173 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/py.typed +0 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/__init__.py +59 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/_lua.py +461 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/breaker.py +86 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/claims.py +226 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/config_store.py +148 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/fallback.py +111 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/limiter.py +482 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/reliability/local_limiter.py +153 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/results/__init__.py +25 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/results/backend.py +226 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/results/dispatcher.py +366 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/results/s3_backend.py +105 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/routing.py +173 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/routing_hooks.py +90 -0
- taskiq_llm_queue-0.1.0a1/src/taskiq_llm_queue/tasks.py +453 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Environments and secrets (never commit tokens: GitHub, PyPI, AWS)
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
.pypirc
|
|
6
|
+
*.pem
|
|
7
|
+
*.key
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# Python build and cache
|
|
12
|
+
__pycache__/
|
|
13
|
+
*.py[cod]
|
|
14
|
+
*.egg-info/
|
|
15
|
+
build/
|
|
16
|
+
dist/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
coverage.xml
|
|
22
|
+
htmlcov/
|
|
23
|
+
|
|
24
|
+
# Test and tool output
|
|
25
|
+
.hypothesis/
|
|
26
|
+
.locust/
|
|
27
|
+
*.log
|
|
28
|
+
reports/
|
|
29
|
+
|
|
30
|
+
# Editors and OS
|
|
31
|
+
.idea/
|
|
32
|
+
.vscode/
|
|
33
|
+
*.swp
|
|
34
|
+
.DS_Store
|
|
35
|
+
Thumbs.db
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are listed here. The project uses semantic versioning; while it is 0.x,
|
|
4
|
+
a minor release may break the public API, with a deprecation announced one minor release ahead
|
|
5
|
+
(see STABILITY.md).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0a1] - 2026-10-29
|
|
10
|
+
|
|
11
|
+
First pre-release. Everything listed below is new.
|
|
12
|
+
|
|
13
|
+
### Changed before 0.1 (breaking against the earlier dev snapshots)
|
|
14
|
+
- Every call returns a result object with `.meta`: `ConverseResult`, `ChatResult`, `EmbedResult`, `InvokeResult`.
|
|
15
|
+
- `timeout=` / `deadline=` became `timeout_secs=` / `deadline_at=`; `request_id=` is accepted on every call.
|
|
16
|
+
- `invoke_model(model, body, *, content_type=, accept=)` replaces guessing from a `"body"` key.
|
|
17
|
+
- Cancellation raises `LLMQueueCancelled`.
|
|
18
|
+
- `__all__` lists only the client surface; wire and plumbing names import from their submodules.
|
|
19
|
+
- `aws_region` defaults to `None`; `redis_password` is a `SecretStr`; `_SECS` env names (old `_S` names warn once).
|
|
20
|
+
- Redis keys are hash-tagged for cluster mode (key schema 2); ids are hashed with keyed BLAKE2b (`LLMQ_ID_HASH_SALT`), and xxhash is no longer a dependency.
|
|
21
|
+
- `QueuedChatBedrockConverse` takes `model_alias=`; an alias passed as `model=` raises.
|
|
22
|
+
- The empty `s3` extra is gone.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Package skeleton (src layout, hatchling, uv, ruff, mypy strict, pytest).
|
|
26
|
+
- Frozen contracts for 0.x:
|
|
27
|
+
- `QueueConfig` / `ModelConfig` / `Target`, with `alias[@target]` resolution and `LLMQ_*` env loading
|
|
28
|
+
- envelope v1 and `LLMReply`, with the version rule and hashed-id labels
|
|
29
|
+
- `request_context`
|
|
30
|
+
- `KeySpace` (Redis key schema)
|
|
31
|
+
- error tree and `ErrorInfo`
|
|
32
|
+
- protocols for providers, limiter, claims, results and receive gates
|
|
33
|
+
- metric names
|
|
34
|
+
- Contract test suite.
|
|
35
|
+
- Docker test stack (Valkey 8.1 with a password, ElasticMQ 1.7.1 with all queues and DLQs).
|
|
36
|
+
- SQS capability check script: ElasticMQ 1.7.1 passes 20/20, moto 5.2.5 server passes 19/20 (no `StartMessageMoveTask`).
|
|
37
|
+
- `HybridSQSBroker`: strict priority across queues with reserved slots, capacity-aware receive, receive gates, real long polls, batched acks and heartbeats, release on shutdown, byte-aware batched send, delays, FIFO, fair queues, expiry, message metadata; unknown tasks and newer envelopes are released, never dropped.
|
|
38
|
+
- Reliability:
|
|
39
|
+
- adaptive concurrency limiter in Lua (baseline start, ramp, AIMD, pause gate, lane share, leases), with a sync twin and a local fallback limiter
|
|
40
|
+
- Lua claim store for exactly-once execution, circuit breaker and fallback resolver
|
|
41
|
+
- live config store with an audit stream
|
|
42
|
+
- Results: JSON result backend (no pickle; `GETDEL`; shared-owner safe) and a per-pod dispatcher. Measured: 1,000 ready results take 1 `MGET`.
|
|
43
|
+
- Providers:
|
|
44
|
+
- Bedrock with SDK retries off (`total_max_attempts=1`), and OpenAI-compatible with `max_retries=0`
|
|
45
|
+
- error-family classification and `Retry-After` parsing
|
|
46
|
+
- provider registry for built-in, import-path and entry-point providers, with allowed and denied hosts
|
|
47
|
+
- `fakellm`: a fake Bedrock + OpenAI server with real error shapes, SigV4 region detection, scenarios, timed phases and an admin API, plus a Docker service.
|
|
48
|
+
- `docs/DESIGN.md`: the delivery-safety rules (DS1–DS19) and broker features (P1–P17, A1–A9) that the code refers to.
|
|
49
|
+
- Makefile and `tools/llmq_dev.py` task runner.
|
|
50
|
+
- S3 offload for large messages (P9) and an S3 result backend (P10).
|
|
51
|
+
- LangChain adapters for Bedrock and OpenAI-compatible chat and embeddings; LangChain 0.3 and 1.x; LangGraph compatible.
|
|
52
|
+
- Ops: canary router, failover to the direct path, `llm-queue` CLI (routing, fallback, limiter, breaker, queues, jobs), metrics hooks.
|
|
53
|
+
- `LLMQueue` client with jobs, cancellation, sync calls and an ordered drain on `stop()`.
|
|
54
|
+
- Performance: orjson for JSON and the SQS wire format, one parse per received message, results encoded once, 6 Valkey round trips per worker call, `speedups` extra (hiredis).
|
|
55
|
+
- Logging: structlog key-value events over stdlib `logging`; the library never configures logging.
|
|
56
|
+
- Reliability fixes from a multi-pod review: infrastructure errors release messages instead of deleting them, owner-checked claims, a pod heartbeat for direct and sync paths, the limiter grows only when busy, throttles never trip the breaker, S3 payloads deleted only after ack.
|
|
57
|
+
- Runnable `samples/` (13), including a replay of a real app's flows against real Bedrock.
|
|
58
|
+
- 619 tests; CI on Python 3.11–3.14, oldest-dependency and LangChain 1.x jobs, integration on the Docker stack; trusted publishing to PyPI.
|
|
59
|
+
|
|
60
|
+
### Known gaps
|
|
61
|
+
- Valkey restarted without persistence wipes all claims; in-flight calls are then treated as lost and may run a second time (S50: 0–16 re-runs). Keep Valkey persistence on.
|
|
62
|
+
- A reply that can't be stored is cached per pod; a redelivery to a different pod calls the provider again.
|
|
63
|
+
- Exhausted jobs reach a dead-letter queue only if their queue has one configured.
|
|
64
|
+
- Cluster-mode Valkey is covered by key-slot tests, not yet by a real cluster run.
|
|
65
|
+
- A call queued during a Valkey outage longer than `valkey_wait_failover_secs` can run twice if a worker takes it before its cancel flag is replayed.
|
|
66
|
+
|
|
67
|
+
### Fixed before release (found by the scenario harness, `tools/scenarios`)
|
|
68
|
+
- Limiter collapse under sporadic throttles: cuts only on clustered throttles (≥2 and ≥5 % in a 1 s window), at most one cut per window, pause only when half the window throttles, growth scales with the limit. 10K burst with 2 % throttles: 22.5 → 76.1 req/s, 6,721 → 10,000 ok (S35).
|
|
69
|
+
- Limit overshoot: growth slows near the last cut point; settles at 57–63 instead of swinging 44–64 (S07).
|
|
70
|
+
- Spurious claim takeovers under load: the heartbeat treated the worker's own just-finished claim as lost and cancelled the result publish (S35c, S27: 19 and 4 losses → 0). A real claim loss now ends the task quietly with a warning, no ERROR traceback.
|
|
71
|
+
- Valkey pools are sized to worker slots + 16; a failed lease release is retried on the next limiter call instead of holding the slot for 240 s.
|
|
72
|
+
- Throttle storms: a cut that does not lower the throttle ratio stops at the start value; a throttled step gets `throttle_retry_attempts` (new, default 6) tries; an rt call walks the fallback chain up to 3 times before `no_capacity`; leases live 60 s (was 240 s, renewed every 10 s) so a killed pod's slots return within a minute; a send queued behind a burst no longer counts as SQS down while other sends complete. 10K burst with 30 % random throttles and a worker kill: 16.3 → 49.7-59.3 req/s, 17.5 % → 99.2 % ok (S35b); S35 76.1 → 90.4 req/s.
|
|
73
|
+
- SQS down: the send is bounded by `sqs_send_timeout_secs` and half the remaining budget, then the call runs direct; first call served in 5.7 s instead of timing out (S20).
|
|
74
|
+
- Valkey down on a send-only pod: result-read errors now trip failover; waiting callers run direct; cancels are replayed on recovery (S21).
|
|
75
|
+
- Target-specific errors (ResourceNotFound, AccessDenied, model not in region, 404) move to the next fallback target instead of ending the call (S38).
|
|
76
|
+
- Per-attempt timeout `attempt_timeout_secs`; configurable provider read/connect timeouts (S39).
|
|
77
|
+
- A worker missing a model alias releases the message unclaimed (jittered delay) instead of acking an `internal_error`; it answers `model_not_configured` only near the delivery budget or deadline (S48).
|
|
78
|
+
|
|
79
|
+
- Valkey blip: a worker releasing work after a Valkey error is no longer cancelled by the claim heartbeat mid-release; the SQS message is released before the claim, so no call is lost (S50: 32/40 → 40/40 ok).
|
|
80
|
+
- Result dispatcher: a new same-lane waiter no longer forces a full read of every pending result (float rounding woke the loop); 1K waiters 43 → 26 MGET/s (S31).
|
|
81
|
+
|
|
82
|
+
### Added before release
|
|
83
|
+
- Scenario harness: 54 scenarios (work, retry, pause, cancel, purge, region change, timeouts, chaos, scale, 10K burst) on an isolated compose profile; `make scenarios`, `make load`, `make chaos`.
|
|
84
|
+
- Scenario harness real-Bedrock mode (`SCN_PROVIDER=aws`): queues stay local, provider calls go to Bedrock; only AWS-safe scenarios run. S01, S03, S16, S17, S29, S40, S41 pass against real Bedrock (~108 calls).
|
|
85
|
+
- Settings: `attempt_timeout_secs`, `provider_read_timeout_secs`, `provider_connect_timeout_secs`, `sqs_send_timeout_secs`, `valkey_wait_failover_secs`, limiter window settings.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The taskiq-llm-queue authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
taskiq-llm-queue
|
|
2
|
+
Copyright (c) 2026 The taskiq-llm-queue authors. Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
The SQS broker's design and feature set build on two MIT-licensed projects. This package is
|
|
5
|
+
an independent implementation; any code adapted from them keeps its original notice below.
|
|
6
|
+
|
|
7
|
+
taskiq-aio-sqs — https://github.com/vonsteer/taskiq-aio-sqs
|
|
8
|
+
Copyright (c) 2022-2024 Jesse Constante. MIT License.
|
|
9
|
+
Ideas used: aiobotocore-based broker, visibility heartbeat, message metadata,
|
|
10
|
+
extended (S3) messages, batching, fair queues.
|
|
11
|
+
|
|
12
|
+
taskiq-sqs — https://github.com/taskiq-python/taskiq-sqs
|
|
13
|
+
Copyright (c) 2026 Mike Shultz and Taskiq team. MIT License.
|
|
14
|
+
Ideas used: multi-queue structure, queue declaration, message expiry,
|
|
15
|
+
batch flush on shutdown, S3 offload middleware and result backend.
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: taskiq-llm-queue
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: An LLM request client with its own durable queue: taskiq + Amazon SQS, adaptive limits, retry and fallback.
|
|
5
|
+
Project-URL: Homepage, https://github.com/techiewonk/taskiq-llm-queue
|
|
6
|
+
Project-URL: Documentation, https://github.com/techiewonk/taskiq-llm-queue#readme
|
|
7
|
+
Project-URL: Source, https://github.com/techiewonk/taskiq-llm-queue
|
|
8
|
+
Project-URL: Issues, https://github.com/techiewonk/taskiq-llm-queue/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/techiewonk/taskiq-llm-queue/blob/main/CHANGELOG.md
|
|
10
|
+
Author: The taskiq-llm-queue authors
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
License-File: NOTICE
|
|
14
|
+
Keywords: bedrock,langchain,langgraph,llm,openai,queue,rate-limit,sqs,taskiq
|
|
15
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Requires-Dist: aiobotocore>=2.23
|
|
30
|
+
Requires-Dist: orjson<4,>=3.10
|
|
31
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
32
|
+
Requires-Dist: redis>=5.0.1
|
|
33
|
+
Requires-Dist: structlog>=24.1
|
|
34
|
+
Requires-Dist: taskiq<0.14,>=0.13
|
|
35
|
+
Provides-Extra: langchain-aws
|
|
36
|
+
Requires-Dist: langchain-aws<2,>=0.2.24; extra == 'langchain-aws'
|
|
37
|
+
Requires-Dist: langchain-core<2,>=0.3.68; extra == 'langchain-aws'
|
|
38
|
+
Provides-Extra: langchain-openai
|
|
39
|
+
Requires-Dist: langchain-core<2,>=0.3.68; extra == 'langchain-openai'
|
|
40
|
+
Requires-Dist: langchain-openai<2,>=0.3.26; extra == 'langchain-openai'
|
|
41
|
+
Provides-Extra: metrics
|
|
42
|
+
Requires-Dist: prometheus-client>=0.20; extra == 'metrics'
|
|
43
|
+
Provides-Extra: openai
|
|
44
|
+
Requires-Dist: httpx<1,>=0.27; extra == 'openai'
|
|
45
|
+
Requires-Dist: openai<4,>=1.40; extra == 'openai'
|
|
46
|
+
Provides-Extra: speedups
|
|
47
|
+
Requires-Dist: hiredis>=3; extra == 'speedups'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# taskiq-llm-queue
|
|
51
|
+
|
|
52
|
+
> **Status:** pre-alpha (`0.1.0a1`). Built and tested: the `LLMQueue` client, the SQS broker, adaptive limiter, claims, circuit breaker, retry and fallback, JSON and S3 result backends, Bedrock and OpenAI-compatible providers, LangChain/LangGraph adapters, canary routing, failover and the CLI. 619 tests on Valkey 8.1, ElasticMQ 1.7.1 and a fake LLM server; CI on Python 3.11–3.14. Breaking changes are still possible before 0.1 (see [CHANGELOG](https://github.com/techiewonk/taskiq-llm-queue/blob/main/CHANGELOG.md)).
|
|
53
|
+
|
|
54
|
+
An LLM request client with its own durable queue, built on [taskiq](https://github.com/taskiq-python/taskiq) and Amazon SQS.
|
|
55
|
+
|
|
56
|
+
Your code keeps calling LangChain models (`ainvoke`, `aembed_query`) or a small client API. Each call becomes a task on an SQS queue. Workers in your own app's processes run it under a limit learned from the provider's throttling, retry and fall back when needed, and return the result through Redis/Valkey.
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
your code ──> client / LangChain adapter ──> SQS (per model: -rt queue first, base, bulk)
|
|
60
|
+
│
|
|
61
|
+
result <── Redis/Valkey <── worker in your pods ──> Bedrock / OpenAI-compatible
|
|
62
|
+
(per-pod dispatcher) (adaptive limiter, retry, fallback)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Why
|
|
66
|
+
|
|
67
|
+
- **Spikes wait in a queue instead of failing.** Throttled calls aren't lost or retried in a storm.
|
|
68
|
+
- **No quota numbers to maintain.** The limit for each model and target is learned from throttle errors and shared by all pods.
|
|
69
|
+
- **Chat first.** Separate `-rt` queues and reserved capacity keep live traffic ahead of background and bulk work.
|
|
70
|
+
- **No extra servers.** The same app pods send and execute; SQS is only the broker.
|
|
71
|
+
- **Drop-in.** LangChain/LangGraph code doesn't change: tool calling, callbacks (Langfuse, LangSmith) and response objects are identical.
|
|
72
|
+
- **Safe rollout.** `direct` mode uses the same limiter, and a live canary moves traffic per model and lane without restarts.
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv add "taskiq-llm-queue[langchain-aws]" # Bedrock + LangChain
|
|
78
|
+
uv add "taskiq-llm-queue[openai,langchain-openai]" # OpenAI-compatible + LangChain
|
|
79
|
+
uv add "taskiq-llm-queue[speedups]" # hiredis parser for redis-py
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| | Packages |
|
|
83
|
+
|---|---|
|
|
84
|
+
| Core | `taskiq`, `aiobotocore`, `redis>=5.0.1`, `pydantic>=2.10`, `orjson`, `structlog` |
|
|
85
|
+
| `openai` | `openai`, `httpx` |
|
|
86
|
+
| `langchain-aws` / `langchain-openai` | LangChain **0.3 and 1.x** are both supported |
|
|
87
|
+
| `metrics` | `prometheus-client` |
|
|
88
|
+
| `speedups` | `hiredis` |
|
|
89
|
+
|
|
90
|
+
The package declares version **ranges**; pin exact versions in your app's lockfile. `aiobotocore` pins a narrow `botocore` window, so let your resolver pick an `aiobotocore` that matches your `boto3`.
|
|
91
|
+
|
|
92
|
+
## Quickstart
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from taskiq_llm_queue import LLMQueue, ModelConfig, QueueConfig, Target, request_context
|
|
96
|
+
from taskiq_llm_queue.langchain import QueuedChatBedrockConverse
|
|
97
|
+
|
|
98
|
+
llmq = LLMQueue(QueueConfig(
|
|
99
|
+
key_prefix="llmq:local",
|
|
100
|
+
redis_host="localhost", redis_port=6379, redis_password="localdev", redis_ssl=False,
|
|
101
|
+
sqs_endpoint_url="http://localhost:9324", # ElasticMQ locally; omit on AWS
|
|
102
|
+
models={
|
|
103
|
+
"gpt120b": ModelConfig(
|
|
104
|
+
provider="bedrock", model_id="openai.gpt-oss-120b-1:0",
|
|
105
|
+
targets={"primary": Target(region="ap-south-1"),
|
|
106
|
+
"apac": Target(profile_id="apac.openai.gpt-oss-120b-1:0")},
|
|
107
|
+
queues={"rt": "llm-gpt120b-rt", "default": "llm-gpt120b"}),
|
|
108
|
+
},
|
|
109
|
+
)) # or QueueConfig.from_env() (LLMQ_* variables)
|
|
110
|
+
|
|
111
|
+
async def main():
|
|
112
|
+
await llmq.start(mode="both") # "send" | "work" | "both"
|
|
113
|
+
model = QueuedChatBedrockConverse(llmq=llmq, model_alias="gpt120b")
|
|
114
|
+
with request_context(lane="rt", session_id="s-1", user_id="u-1"):
|
|
115
|
+
reply = await model.ainvoke("Summarise today's market in one line.")
|
|
116
|
+
print(reply.content, reply.response_metadata["served_model"])
|
|
117
|
+
await llmq.stop() # drains in-flight work
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
In a FastAPI app, call `start()` in the lifespan startup and `stop()` on shutdown. Send-only processes, such as CLIs and scripts, use `mode="send"`.
|
|
121
|
+
|
|
122
|
+
## Client API
|
|
123
|
+
|
|
124
|
+
Every call returns a result object with `.meta`: `served_model`, `served_target`, `fallback_used`, `attempts`, `queue_wait_ms`, `provider_ms`, `worker`.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
res = await llmq.converse("gpt120b", {"messages": [...], "system": [...], "toolConfig": ...},
|
|
128
|
+
lane="rt", fallback="chain", timeout_secs=90, request_id="req-1")
|
|
129
|
+
res.response # exact Bedrock Converse dict
|
|
130
|
+
|
|
131
|
+
emb = await llmq.embed("titan", ["a", "b"], lane="bulk")
|
|
132
|
+
emb.vectors # list[list[float]]; `emb` itself also works as a list
|
|
133
|
+
|
|
134
|
+
chat = await llmq.chat("llama", {"messages": [{"role": "user", "content": "hi"}]})
|
|
135
|
+
chat.response # OpenAI-compatible chat completion dict
|
|
136
|
+
|
|
137
|
+
raw = await llmq.invoke_model("titan", {"inputText": "a"}, content_type="application/json")
|
|
138
|
+
raw.json(), raw.body, raw.content_type
|
|
139
|
+
|
|
140
|
+
await llmq.cancel("req-1") # not run if not started; the caller gets LLMQueueCancelled
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- **Timing:** `timeout_secs` (relative) or `deadline_at` (absolute epoch seconds) on every call.
|
|
144
|
+
- **Model ids** are aliases, optionally with a target: `gpt120b`, `gpt120b@apac`. Unknown aliases are rejected when the config is built.
|
|
145
|
+
- **Errors:** everything derives from `LLMQueueError`: `LLMQueueTimeout`, `LLMQueueCancelled`, `ProviderError(retryable, retry_after_secs)`, `QueueUnavailable`, `ConfigError`.
|
|
146
|
+
- **Sync callers:** `converse_sync`, `chat_sync`, `embed_sync`, `invoke_model_sync` run `direct` under the shared limiter (counted as sync fallbacks). Set `strict_async=True` to raise instead. `close_sync()` (or `stop()`) closes the sync runner.
|
|
147
|
+
- **Jobs:** `register_job(name, fn)` on workers, `send_job(name, payload, timeout_secs=...)` anywhere; jobs run on the job queues with the same claims and DLQ rules.
|
|
148
|
+
|
|
149
|
+
## LangChain and LangGraph
|
|
150
|
+
|
|
151
|
+
| Class | Based on | How |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `QueuedChatBedrockConverse` | `langchain_aws.ChatBedrockConverse` | Builds the request and parses the reply with the installed langchain-aws's own code, so `bind_tools`, structured output, cache usage and service tier match exactly |
|
|
154
|
+
| `QueuedBedrockEmbeddings` | `langchain_aws.BedrockEmbeddings` | `aembed_query` / `aembed_documents` go through the queue |
|
|
155
|
+
| `QueuedChatOpenAI` | `langchain_openai.ChatOpenAI` | `http_async_client` with a queued httpx transport: chat completions and embeddings go through the queue; queue meta lands in `response_metadata`. The Responses API (`/responses`) is not supported yet |
|
|
156
|
+
| `QueuedOpenAIEmbeddings` | `langchain_openai.OpenAIEmbeddings` | Same transport; `check_embedding_ctx_length=False` by default for non-OpenAI servers |
|
|
157
|
+
|
|
158
|
+
- **Model names:** pass the queue alias as `model_alias=` (plus `model_id=` or `llmq=`). Passing an alias as `model=` raises a clear error.
|
|
159
|
+
- **Streaming:** all queued classes force `disable_streaming=True` / `streaming=False`. `astream()` and LangGraph `stream_mode="messages"` return one complete message, so nothing bypasses the queue.
|
|
160
|
+
- **Laziness:** queued classes find the running `LLMQueue` on first use and check routing and the kill switch on every call, so you can create them at import time.
|
|
161
|
+
|
|
162
|
+
## Configuration
|
|
163
|
+
|
|
164
|
+
`QueueConfig.from_env()` reads `LLMQ_*` variables (durations end in `_SECS`; older `_S` names still work with a one-time warning). Notable settings:
|
|
165
|
+
|
|
166
|
+
| Setting | Default | Note |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `aws_region` | `None` | Left to the AWS credential chain / IRSA |
|
|
169
|
+
| `redis_url` or `redis_host`/`port`/`username`/`password`/`ssl` | localhost | `redis_password` is a `SecretStr` |
|
|
170
|
+
| `redis_socket_timeout_secs` / `connect_timeout` / `health_check_interval` | 5 / 2 / 30 | Connection errors retried 3× with backoff |
|
|
171
|
+
| `id_hash_salt` (`LLMQ_ID_HASH_SALT`) | library constant | **Set your own in production**; keys the BLAKE2b hash of session/user ids |
|
|
172
|
+
| `timeout_secs` per lane | rt 90, bg 300, bulk 900 | |
|
|
173
|
+
| `attempt_timeout_secs` | 180 | One provider attempt; a hung target moves to the next fallback step |
|
|
174
|
+
| `provider_read_timeout_secs` / `provider_connect_timeout_secs` | 300 / 10 | Bedrock botocore and OpenAI httpx timeouts |
|
|
175
|
+
| `sqs_send_timeout_secs` | 5 | SQS counts as down only when no send completed for this long; then the call runs direct |
|
|
176
|
+
| `throttle_retry_attempts` | 6 | Tries per fallback step for throttles (backoff holds no slot) |
|
|
177
|
+
| `valkey_wait_failover_secs` | 10 | Result reads failing this long make waiting callers run direct |
|
|
178
|
+
| `redis_max_connections` | 32 | Raised automatically to worker slots + 16 |
|
|
179
|
+
|
|
180
|
+
Redis keys are cluster-safe (hash-tagged per request and per model+target), so cluster-mode Valkey (e.g. ElastiCache Serverless) works.
|
|
181
|
+
|
|
182
|
+
## Providers
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
ModelConfig(provider="openai", model_id="llama-3.1-70b",
|
|
186
|
+
targets={"primary": Target(base_url="http://vllm:8000/v1")}, api_key_env="LOCAL_LLM_KEY")
|
|
187
|
+
QueueConfig(allowed_providers={"bedrock"}, denied_hosts=["*.openai.azure.com"]) # enforced on sender and worker
|
|
188
|
+
QueueConfig(providers={"myprov": "mypkg.providers:MyProvider"}) # custom provider, loaded by every pod
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A custom provider implements `async call(op, params, target) -> dict` and `classify(exc) -> ErrorInfo(kind="throttle" | "retryable" | "fatal", retry_after_secs)`. It can also be registered through the `taskiq_llm_queue.providers` entry point. SDK-level retries are off inside workers (botocore `total_max_attempts=1`, openai `max_retries=0`), so throttles reach the limiter.
|
|
192
|
+
|
|
193
|
+
## Lanes and queues
|
|
194
|
+
|
|
195
|
+
| Lane | Queue | Use |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| `rt` | `llm-<model>-rt` | Live user requests; polled first; may use the whole limit |
|
|
198
|
+
| `bg` | `llm-<model>` | Background work; at most 80 % of the limit |
|
|
199
|
+
| `bulk` | `llm-<model>-bulk` if configured (e.g. embeddings), else base | Ingestion/batch; lowest priority |
|
|
200
|
+
|
|
201
|
+
A queued `bg`/`bulk` call waits at most 15 s for a slot, then goes back to the queue with a delay instead of holding a worker.
|
|
202
|
+
|
|
203
|
+
## Results
|
|
204
|
+
|
|
205
|
+
Results are stored as JSON (never pickle) for 5 minutes and read once (`GETDEL`). Each pod runs one result dispatcher: a pub/sub wake reads only the announced results, and a periodic batched sweep catches the rest, so 10K waiting calls don't mean 10K polling loops.
|
|
206
|
+
|
|
207
|
+
## Fallbacks: how to check and update
|
|
208
|
+
|
|
209
|
+
A fallback chain is tried **inside the worker**. Each step is retried with backoff before moving on:
|
|
210
|
+
|
|
211
|
+
1. same model, primary target
|
|
212
|
+
2. same model, another target (e.g. a cross-region profile `gpt120b@apac`)
|
|
213
|
+
3. another model (only when the call's policy is `chain`)
|
|
214
|
+
|
|
215
|
+
Embeddings never switch model. Throttles shrink the limiter but never trip the circuit breaker; only 5xx and timeouts do.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
uv run llm-queue fallback get # whole config (JSON); starts from your app's defaults
|
|
219
|
+
uv run llm-queue fallback get --model gpt120b # resolved chain for one model
|
|
220
|
+
uv run llm-queue fallback test --model gpt120b # dry run: which target would serve now
|
|
221
|
+
uv run llm-queue fallback set --model gpt120b --chain "gpt20b"
|
|
222
|
+
uv run llm-queue fallback set --file fallback.json # merges per model; --replace to overwrite all
|
|
223
|
+
uv run llm-queue fallback reset # back to the startup default
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Chains are validated and reloaded by every pod within 30 s with no restart. An invalid config is rejected and the last good one stays. Every change is written to an audit stream.
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{ "chains": {
|
|
230
|
+
"gpt120b": ["gpt20b"],
|
|
231
|
+
"gpt20b": [{"model": "gpt120b", "require_headroom": true}]
|
|
232
|
+
},
|
|
233
|
+
"skip_targets": { "gpt120b": ["apac"] } }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**To see fallbacks happen:** `res.meta` / `response_metadata` show `served_model`, `served_target`, `fallback_used` and `attempts`, and the `llmq_fallback_total{model,step}` metric counts them. `samples/05_fallback_on_throttle.py` shows it end to end.
|
|
237
|
+
|
|
238
|
+
## Operating it
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
uv run llm-queue routing set --model gpt20b --lane rt --pct 10 # canary: 10 % of sessions via SQS
|
|
242
|
+
uv run llm-queue routing kill on # everything direct within 30 s
|
|
243
|
+
uv run llm-queue routing reset
|
|
244
|
+
uv run llm-queue limiter show --model gpt120b # learned limit, in-flight, pause
|
|
245
|
+
uv run llm-queue limiter reset --model gpt120b [--target apac] # forget the learned limit
|
|
246
|
+
uv run llm-queue breaker show | reset
|
|
247
|
+
uv run llm-queue queues # messages per queue and DLQ
|
|
248
|
+
uv run llm-queue job send ingest --payload-file payload.json --queue jobs-ingest
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Failure behaviour
|
|
252
|
+
|
|
253
|
+
| Failure | What happens |
|
|
254
|
+
|---|---|
|
|
255
|
+
| Provider throttles | Limiter shrinks and pauses that model + target; brokers stop receiving it unless another target in the chain has headroom; work waits in SQS |
|
|
256
|
+
| Provider down (5xx, timeouts) | Circuit breaker opens → next fallback step |
|
|
257
|
+
| Worker killed mid-task | Message reappears after the visibility timeout; the claim prevents a second provider call |
|
|
258
|
+
| Rolling deploy | Pods stop receiving at once, release unstarted work, finish running calls, then exit (90 s budget in the reference deployment) |
|
|
259
|
+
| **SQS unreachable** | That model switches to `direct` (+ shared limiter) for 300 s on transient send errors only. An ambiguous send is claimed first, so it can't run twice |
|
|
260
|
+
| **Redis/Valkey unreachable** | Workers release messages instead of losing them; senders run `direct` with a local per-pod cap (last known limit ÷ live pods) |
|
|
261
|
+
|
|
262
|
+
## Logging
|
|
263
|
+
|
|
264
|
+
The library logs with structlog's key-value API on top of stdlib `logging` and **never configures logging itself**. Records are named `taskiq_llm_queue.*`; the message reads `event key=value ...` and the fields are also on `record.llmq` (a dict). Prompts, responses and params are never logged.
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
import logging
|
|
268
|
+
logging.getLogger("taskiq_llm_queue").setLevel(logging.INFO) # stdlib apps: that's all
|
|
269
|
+
|
|
270
|
+
# loguru apps: the usual InterceptHandler picks these up like any stdlib logger.
|
|
271
|
+
# structlog apps: use structlog.stdlib.ProcessorFormatter on your handler to render them.
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The `llm-queue` CLI logs warnings and errors to stderr, so its stdout is always plain JSON.
|
|
275
|
+
|
|
276
|
+
## Security and data
|
|
277
|
+
|
|
278
|
+
- Encryption is server-side on the queues (SSE-SQS or SSE-KMS); the library needs no setting for either.
|
|
279
|
+
- Prompts and responses are never logged. `user_id` / `session_id` are hashed (keyed BLAKE2b) in task labels; raw ids stay inside the message body.
|
|
280
|
+
- Nothing secret goes into messages; credentials come from the AWS chain (IRSA / Pod Identity) or env.
|
|
281
|
+
- Unknown task names or newer message versions are released for another pod, never silently dropped.
|
|
282
|
+
|
|
283
|
+
## Run it locally (no AWS needed)
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
make up # Valkey (password) + ElasticMQ + fakellm
|
|
287
|
+
make test # unit + integration against the stack
|
|
288
|
+
make down
|
|
289
|
+
# Windows without make: uv run python tools/llmq_dev.py up | test | down
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Runnable examples are in [`samples/`](https://github.com/techiewonk/taskiq-llm-queue/tree/main/samples): quickstart, chat, embeddings, lanes, fallback on throttle, send-only and worker pods, LangChain, LangGraph, jobs, cancel and timeouts, sync calls, CLI, and `13_research_assistant_flow.py`, which replays a real app's flows locally or against real Bedrock (`--target aws`, settings from `.env` or AWS Secrets Manager). The stack, capability checks and test layers are described in [docs/TESTING.md](https://github.com/techiewonk/taskiq-llm-queue/blob/main/docs/TESTING.md); delivery-safety rules in [docs/DESIGN.md](https://github.com/techiewonk/taskiq-llm-queue/blob/main/docs/DESIGN.md).
|
|
293
|
+
|
|
294
|
+
## Credits
|
|
295
|
+
|
|
296
|
+
Broker design and feature set build on [taskiq-aio-sqs](https://github.com/vonsteer/taskiq-aio-sqs) (MIT, © 2022-2024 Jesse Constante) and [taskiq-sqs](https://github.com/taskiq-python/taskiq-sqs) (MIT, © 2026 Mike Shultz and the Taskiq team). Licence: MIT.
|