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.
- taskiq_llm_queue/__init__.py +138 -0
- taskiq_llm_queue/_logging.py +40 -0
- taskiq_llm_queue/_version.py +5 -0
- taskiq_llm_queue/broker/__init__.py +86 -0
- taskiq_llm_queue/broker/acker.py +239 -0
- taskiq_llm_queue/broker/batching.py +188 -0
- taskiq_llm_queue/broker/broker.py +937 -0
- taskiq_llm_queue/broker/delivery.py +50 -0
- taskiq_llm_queue/broker/errors.py +178 -0
- taskiq_llm_queue/broker/events.py +51 -0
- taskiq_llm_queue/broker/filters.py +32 -0
- taskiq_llm_queue/broker/heartbeat.py +157 -0
- taskiq_llm_queue/broker/queue.py +141 -0
- taskiq_llm_queue/broker/s3_offload.py +346 -0
- taskiq_llm_queue/broker/scheduler.py +113 -0
- taskiq_llm_queue/broker/serializer.py +32 -0
- taskiq_llm_queue/cli.py +488 -0
- taskiq_llm_queue/client.py +1409 -0
- taskiq_llm_queue/config.py +592 -0
- taskiq_llm_queue/context.py +62 -0
- taskiq_llm_queue/core.py +832 -0
- taskiq_llm_queue/envelope.py +136 -0
- taskiq_llm_queue/errors.py +71 -0
- taskiq_llm_queue/failover.py +286 -0
- taskiq_llm_queue/http_transport.py +220 -0
- taskiq_llm_queue/keys.py +87 -0
- taskiq_llm_queue/langchain/__init__.py +64 -0
- taskiq_llm_queue/langchain/_client.py +142 -0
- taskiq_llm_queue/langchain/bedrock.py +291 -0
- taskiq_llm_queue/langchain/openai.py +103 -0
- taskiq_llm_queue/lifecycle.py +512 -0
- taskiq_llm_queue/metrics/__init__.py +45 -0
- taskiq_llm_queue/metrics/emf.py +100 -0
- taskiq_llm_queue/metrics/prometheus.py +66 -0
- taskiq_llm_queue/metrics_names.py +19 -0
- taskiq_llm_queue/protocols.py +140 -0
- taskiq_llm_queue/providers/__init__.py +62 -0
- taskiq_llm_queue/providers/bedrock.py +188 -0
- taskiq_llm_queue/providers/classify.py +251 -0
- taskiq_llm_queue/providers/embeddings.py +87 -0
- taskiq_llm_queue/providers/hosts.py +34 -0
- taskiq_llm_queue/providers/openai_compat.py +142 -0
- taskiq_llm_queue/providers/registry.py +173 -0
- taskiq_llm_queue/py.typed +0 -0
- taskiq_llm_queue/reliability/__init__.py +59 -0
- taskiq_llm_queue/reliability/_lua.py +461 -0
- taskiq_llm_queue/reliability/breaker.py +86 -0
- taskiq_llm_queue/reliability/claims.py +226 -0
- taskiq_llm_queue/reliability/config_store.py +148 -0
- taskiq_llm_queue/reliability/fallback.py +111 -0
- taskiq_llm_queue/reliability/limiter.py +482 -0
- taskiq_llm_queue/reliability/local_limiter.py +153 -0
- taskiq_llm_queue/results/__init__.py +25 -0
- taskiq_llm_queue/results/backend.py +226 -0
- taskiq_llm_queue/results/dispatcher.py +366 -0
- taskiq_llm_queue/results/s3_backend.py +105 -0
- taskiq_llm_queue/routing.py +173 -0
- taskiq_llm_queue/routing_hooks.py +90 -0
- taskiq_llm_queue/tasks.py +453 -0
- taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
- taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
- taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
- taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""httpx transport that turns OpenAI SDK HTTP requests into LLM queue calls (D88).
|
|
2
|
+
|
|
3
|
+
Given to `openai.AsyncOpenAI(http_client=httpx.AsyncClient(transport=...))`, it answers
|
|
4
|
+
`/chat/completions` with `llmq.chat` and `/embeddings` with `llmq.embed`, so the SDK and
|
|
5
|
+
LangChain parse replies exactly as from a real server. Queue errors become OpenAI-style error
|
|
6
|
+
bodies with a matching status, so the SDK raises its usual exceptions (429 → `RateLimitError`).
|
|
7
|
+
Chat replies carry how the queue served them under the extra body key `llmq_meta`.
|
|
8
|
+
Needs the `openai` extra (httpx); the core package never imports this module.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import array
|
|
14
|
+
import base64
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import orjson
|
|
19
|
+
|
|
20
|
+
from taskiq_llm_queue.errors import (
|
|
21
|
+
ConfigError,
|
|
22
|
+
LLMQueueCancelled,
|
|
23
|
+
LLMQueueError,
|
|
24
|
+
LLMQueueTimeout,
|
|
25
|
+
ProviderError,
|
|
26
|
+
QueueUnavailable,
|
|
27
|
+
)
|
|
28
|
+
from taskiq_llm_queue.langchain._client import resolve_queue
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from taskiq_llm_queue.config import Lane
|
|
32
|
+
from taskiq_llm_queue.envelope import FallbackPolicy
|
|
33
|
+
from taskiq_llm_queue.langchain._client import ChatResultLike, QueueClient
|
|
34
|
+
|
|
35
|
+
# Never resolved: every request is answered in-process by the transport.
|
|
36
|
+
QUEUED_BASE_URL = "http://llmq.invalid/v1"
|
|
37
|
+
# Extra key of a queued chat completion body: the call's `CallMeta` (served model, attempts...).
|
|
38
|
+
META_KEY = "llmq_meta"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RequestRejected(Exception):
|
|
42
|
+
"""A request the transport cannot queue (bad body, streaming, unknown route)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, status: int, message: str, code: str) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.status = status
|
|
47
|
+
self.code = code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QueuedHTTPTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
|
|
51
|
+
"""Answer OpenAI-compatible HTTP requests from the LLM queue instead of the network.
|
|
52
|
+
|
|
53
|
+
`model_alias` overrides the body's `model` (use it when the SDK model name differs from the
|
|
54
|
+
queue alias). `llmq=None` uses the default queue, looked up per request. Auth headers are
|
|
55
|
+
ignored: workers hold the provider credentials. The sync side needs `chat_sync` on the client
|
|
56
|
+
for chat (embeddings use `embed_sync`); without it the SDK gets a 501 error.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
llmq: QueueClient | None = None,
|
|
62
|
+
model_alias: str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
lane: Lane | str | None = None,
|
|
65
|
+
fallback: FallbackPolicy | None = None,
|
|
66
|
+
timeout_secs: float | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Keep the queue, alias and per-call overrides; nothing is resolved until a request."""
|
|
69
|
+
self._llmq = llmq
|
|
70
|
+
self._alias = model_alias
|
|
71
|
+
self._lane = lane
|
|
72
|
+
self._fallback = fallback
|
|
73
|
+
self._timeout_secs = timeout_secs
|
|
74
|
+
|
|
75
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
76
|
+
"""Route one SDK request through the async queue API."""
|
|
77
|
+
try:
|
|
78
|
+
route, alias, body = self._parse(request, await request.aread())
|
|
79
|
+
queue = resolve_queue(self._llmq)
|
|
80
|
+
if route == "chat":
|
|
81
|
+
chat = await queue.chat(
|
|
82
|
+
alias,
|
|
83
|
+
body,
|
|
84
|
+
lane=self._lane,
|
|
85
|
+
fallback=self._fallback,
|
|
86
|
+
timeout_secs=self._timeout_secs,
|
|
87
|
+
)
|
|
88
|
+
data = _chat_response(chat)
|
|
89
|
+
else:
|
|
90
|
+
texts = _embedding_inputs(body)
|
|
91
|
+
embedded = await queue.embed(
|
|
92
|
+
alias, texts, lane=self._lane, timeout_secs=self._timeout_secs
|
|
93
|
+
)
|
|
94
|
+
data = _embedding_response(alias, embedded.vectors, body.get("encoding_format"))
|
|
95
|
+
except (_RequestRejected, LLMQueueError) as exc:
|
|
96
|
+
return _error_response(request, exc)
|
|
97
|
+
return httpx.Response(200, json=data, request=request)
|
|
98
|
+
|
|
99
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
100
|
+
"""Route one SDK request through the sync queue API (`chat_sync` / `embed_sync`)."""
|
|
101
|
+
try:
|
|
102
|
+
route, alias, body = self._parse(request, request.read())
|
|
103
|
+
queue = resolve_queue(self._llmq)
|
|
104
|
+
if route == "chat":
|
|
105
|
+
chat_sync = getattr(queue, "chat_sync", None)
|
|
106
|
+
if chat_sync is None:
|
|
107
|
+
raise _RequestRejected(
|
|
108
|
+
501, "sync chat is not supported by this queue; use ainvoke", "sync"
|
|
109
|
+
)
|
|
110
|
+
chat = chat_sync(
|
|
111
|
+
alias,
|
|
112
|
+
body,
|
|
113
|
+
lane=self._lane,
|
|
114
|
+
fallback=self._fallback,
|
|
115
|
+
timeout_secs=self._timeout_secs,
|
|
116
|
+
)
|
|
117
|
+
data = _chat_response(chat)
|
|
118
|
+
else:
|
|
119
|
+
texts = _embedding_inputs(body)
|
|
120
|
+
embedded = queue.embed_sync(
|
|
121
|
+
alias, texts, lane=self._lane, timeout_secs=self._timeout_secs
|
|
122
|
+
)
|
|
123
|
+
data = _embedding_response(alias, embedded.vectors, body.get("encoding_format"))
|
|
124
|
+
except (_RequestRejected, LLMQueueError) as exc:
|
|
125
|
+
return _error_response(request, exc)
|
|
126
|
+
return httpx.Response(200, json=data, request=request)
|
|
127
|
+
|
|
128
|
+
def _parse(self, request: httpx.Request, raw: bytes) -> tuple[str, str, dict[str, Any]]:
|
|
129
|
+
"""Return (route, alias, JSON body), rejecting what the queue cannot serve."""
|
|
130
|
+
path = request.url.path.rstrip("/")
|
|
131
|
+
if request.method != "POST":
|
|
132
|
+
raise _RequestRejected(405, f"{request.method} {path} is not queued", "method")
|
|
133
|
+
if path.endswith("/chat/completions"):
|
|
134
|
+
route = "chat"
|
|
135
|
+
elif path.endswith("/embeddings"):
|
|
136
|
+
route = "embeddings"
|
|
137
|
+
else:
|
|
138
|
+
raise _RequestRejected(404, f"route {path} is not supported by the queue", "route")
|
|
139
|
+
try:
|
|
140
|
+
body = orjson.loads(raw or b"{}")
|
|
141
|
+
except ValueError as exc:
|
|
142
|
+
raise _RequestRejected(400, f"request body is not JSON: {exc}", "body") from exc
|
|
143
|
+
if not isinstance(body, dict):
|
|
144
|
+
raise _RequestRejected(400, "request body must be a JSON object", "body")
|
|
145
|
+
if body.get("stream"):
|
|
146
|
+
raise _RequestRejected(400, "streaming is not supported through the queue", "stream")
|
|
147
|
+
alias = self._alias or body.get("model")
|
|
148
|
+
if not isinstance(alias, str) or not alias:
|
|
149
|
+
raise _RequestRejected(400, "no model alias in the transport or the body", "model")
|
|
150
|
+
return route, alias, body
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _chat_response(result: ChatResultLike) -> dict[str, Any]:
|
|
154
|
+
"""The completion body plus `llmq_meta` (the SDK keeps unknown keys as extras)."""
|
|
155
|
+
return {**result.response, META_KEY: result.meta.model_dump(mode="json")}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _embedding_inputs(body: dict[str, Any]) -> list[str]:
|
|
159
|
+
"""Texts from an embeddings body; token-id inputs are rejected (queue embeds text only)."""
|
|
160
|
+
value = body.get("input")
|
|
161
|
+
if isinstance(value, str):
|
|
162
|
+
return [value]
|
|
163
|
+
if isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
164
|
+
return value
|
|
165
|
+
raise _RequestRejected(400, "embeddings input must be a string or a list of strings", "input")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _embedding_response(
|
|
169
|
+
alias: str, vectors: list[list[float]], encoding_format: object
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""OpenAI embeddings body; base64 is float32 in native order, as the SDK decodes it."""
|
|
172
|
+
as_base64 = encoding_format == "base64"
|
|
173
|
+
return {
|
|
174
|
+
"object": "list",
|
|
175
|
+
"model": alias,
|
|
176
|
+
"data": [
|
|
177
|
+
{
|
|
178
|
+
"object": "embedding",
|
|
179
|
+
"index": i,
|
|
180
|
+
"embedding": (
|
|
181
|
+
base64.b64encode(array.array("f", vec).tobytes()).decode("ascii")
|
|
182
|
+
if as_base64
|
|
183
|
+
else vec
|
|
184
|
+
),
|
|
185
|
+
}
|
|
186
|
+
for i, vec in enumerate(vectors)
|
|
187
|
+
],
|
|
188
|
+
"usage": {"prompt_tokens": 0, "total_tokens": 0},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _error_response(request: httpx.Request, exc: Exception) -> httpx.Response:
|
|
193
|
+
"""Map a queue error to the status and body an OpenAI server would send.
|
|
194
|
+
|
|
195
|
+
`ProviderError` uses its `status_code` when the client sets one, else 429 when retryable
|
|
196
|
+
(the SDK raises `RateLimitError`) and 400 when not; timeouts are 504, queue outages 503.
|
|
197
|
+
"""
|
|
198
|
+
headers = {"x-llmq-error": type(exc).__name__}
|
|
199
|
+
if isinstance(exc, _RequestRejected):
|
|
200
|
+
status, err_type, code = exc.status, "invalid_request_error", exc.code
|
|
201
|
+
elif isinstance(exc, ProviderError):
|
|
202
|
+
explicit = getattr(exc, "status_code", None)
|
|
203
|
+
status = explicit if isinstance(explicit, int) else (429 if exc.retryable else 400)
|
|
204
|
+
err_type = "rate_limit_error" if status == 429 else "provider_error"
|
|
205
|
+
code = "provider_error"
|
|
206
|
+
if exc.retry_after_secs is not None:
|
|
207
|
+
headers["retry-after"] = f"{exc.retry_after_secs:g}"
|
|
208
|
+
headers["retry-after-ms"] = str(int(exc.retry_after_secs * 1000))
|
|
209
|
+
elif isinstance(exc, LLMQueueCancelled):
|
|
210
|
+
status, err_type, code = 499, "cancelled", "cancelled"
|
|
211
|
+
elif isinstance(exc, LLMQueueTimeout):
|
|
212
|
+
status, err_type, code = 504, "timeout", "queue_timeout"
|
|
213
|
+
elif isinstance(exc, QueueUnavailable):
|
|
214
|
+
status, err_type, code = 503, "service_unavailable", "queue_unavailable"
|
|
215
|
+
elif isinstance(exc, ConfigError):
|
|
216
|
+
status, err_type, code = 404, "invalid_request_error", "model_not_found"
|
|
217
|
+
else:
|
|
218
|
+
status, err_type, code = 500, "server_error", "llmq_error"
|
|
219
|
+
body = {"error": {"message": str(exc), "type": err_type, "param": None, "code": code}}
|
|
220
|
+
return httpx.Response(status, json=body, headers=headers, request=request)
|
taskiq_llm_queue/keys.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Every Redis/Valkey key and channel name, built from one configurable prefix.
|
|
2
|
+
|
|
3
|
+
Keeping names in one place lets several environments share a Redis without collisions and
|
|
4
|
+
keeps the state schema (versioned under `schema`) easy to migrate. Keys that one Lua script
|
|
5
|
+
touches together share a hash tag (`{...}`), so they map to one slot on cluster-mode Valkey.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# 2: hash tags for cluster mode (`{m:<model>:<target>}`, `{r:<request_id>}`, `{cfg}`).
|
|
13
|
+
SCHEMA_VERSION = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class KeySpace:
|
|
18
|
+
"""Key builder for one prefix, e.g. `llmq:prod` -> `llmq:prod:{r:<id>}:job`.
|
|
19
|
+
|
|
20
|
+
Tags group the keys of one script: `{r:<request_id>}` (claim, cancel, params, result),
|
|
21
|
+
`{m:<model>:<target>}` (limiter state, leases, breaker) and `{cfg}` (live config, audit).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
prefix: str
|
|
25
|
+
|
|
26
|
+
def _request(self, request_id: str, name: str) -> str:
|
|
27
|
+
return f"{self.prefix}:{{r:{request_id}}}:{name}"
|
|
28
|
+
|
|
29
|
+
def _target(self, model: str, target: str, name: str) -> str:
|
|
30
|
+
return f"{self.prefix}:{{m:{model}:{target}}}:{name}"
|
|
31
|
+
|
|
32
|
+
def job(self, request_id: str) -> str:
|
|
33
|
+
"""Claim state of one task (hash): state, worker, heartbeat, failures."""
|
|
34
|
+
return self._request(request_id, "job")
|
|
35
|
+
|
|
36
|
+
def params(self, request_id: str) -> str:
|
|
37
|
+
"""Large params stored by reference (string)."""
|
|
38
|
+
return self._request(request_id, "params")
|
|
39
|
+
|
|
40
|
+
def cancel(self, request_id: str) -> str:
|
|
41
|
+
"""Cancel flag for one task (string)."""
|
|
42
|
+
return self._request(request_id, "cancel")
|
|
43
|
+
|
|
44
|
+
def result(self, task_id: str) -> str:
|
|
45
|
+
"""JSON result of one task, deleted on read (string); same slot as its claim."""
|
|
46
|
+
return self._request(task_id, "res")
|
|
47
|
+
|
|
48
|
+
def limiter(self, model: str, target: str) -> str:
|
|
49
|
+
"""Adaptive limiter state for one model target (hash, no TTL)."""
|
|
50
|
+
return self._target(model, target, "ac")
|
|
51
|
+
|
|
52
|
+
def leases(self, model: str, target: str) -> str:
|
|
53
|
+
"""In-flight slot leases scored by expiry (sorted set)."""
|
|
54
|
+
return self._target(model, target, "lease")
|
|
55
|
+
|
|
56
|
+
def breaker(self, model: str, target: str) -> str:
|
|
57
|
+
"""Circuit breaker state for one model target (hash, no TTL)."""
|
|
58
|
+
return self._target(model, target, "cb")
|
|
59
|
+
|
|
60
|
+
def done_channel(self, pod: str) -> str:
|
|
61
|
+
"""Pub/sub channel that wakes one pod's result dispatcher."""
|
|
62
|
+
return f"{self.prefix}:done:{pod}"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def routing(self) -> str:
|
|
66
|
+
"""Live canary routing config (JSON string)."""
|
|
67
|
+
return f"{self.prefix}:{{cfg}}:routing"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def fallback(self) -> str:
|
|
71
|
+
"""Live fallback chains (JSON string)."""
|
|
72
|
+
return f"{self.prefix}:{{cfg}}:fallback"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def audit(self) -> str:
|
|
76
|
+
"""Audit stream of config changes (who, before, after)."""
|
|
77
|
+
return f"{self.prefix}:{{cfg}}:audit"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def pods(self) -> str:
|
|
81
|
+
"""Live pods of this prefix (sorted set scored by expiry)."""
|
|
82
|
+
return f"{self.prefix}:pods"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def schema(self) -> str:
|
|
86
|
+
"""State schema version marker."""
|
|
87
|
+
return f"{self.prefix}:schema"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""LangChain adapters that send model calls through the LLM queue (extras `langchain-*`).
|
|
2
|
+
|
|
3
|
+
The adapter classes are loaded on first attribute access, so importing this package (for
|
|
4
|
+
example to call `set_default_queue` from `LLMQueue.start()`) never imports langchain, boto3 or
|
|
5
|
+
httpx. `QueuedChatBedrockConverse` / `QueuedBedrockEmbeddings` need `[langchain-aws]`;
|
|
6
|
+
`QueuedChatOpenAI` / `QueuedOpenAIEmbeddings` need `[openai,langchain-openai]`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from taskiq_llm_queue.langchain._client import (
|
|
15
|
+
ChatResultLike,
|
|
16
|
+
ConverseResultLike,
|
|
17
|
+
EmbedResultLike,
|
|
18
|
+
QueueClient,
|
|
19
|
+
get_default_queue,
|
|
20
|
+
set_default_queue,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from taskiq_llm_queue.langchain.bedrock import (
|
|
25
|
+
QueuedBedrockEmbeddings,
|
|
26
|
+
QueuedChatBedrockConverse,
|
|
27
|
+
)
|
|
28
|
+
from taskiq_llm_queue.langchain.openai import QueuedChatOpenAI, QueuedOpenAIEmbeddings
|
|
29
|
+
|
|
30
|
+
# Lazy exports: attribute name -> (module, extra that provides its dependencies).
|
|
31
|
+
_LAZY = {
|
|
32
|
+
"QueuedChatBedrockConverse": ("bedrock", "langchain-aws"),
|
|
33
|
+
"QueuedBedrockEmbeddings": ("bedrock", "langchain-aws"),
|
|
34
|
+
"QueuedChatOpenAI": ("openai", "openai,langchain-openai"),
|
|
35
|
+
"QueuedOpenAIEmbeddings": ("openai", "openai,langchain-openai"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ChatResultLike",
|
|
40
|
+
"ConverseResultLike",
|
|
41
|
+
"EmbedResultLike",
|
|
42
|
+
"QueueClient",
|
|
43
|
+
"QueuedBedrockEmbeddings",
|
|
44
|
+
"QueuedChatBedrockConverse",
|
|
45
|
+
"QueuedChatOpenAI",
|
|
46
|
+
"QueuedOpenAIEmbeddings",
|
|
47
|
+
"get_default_queue",
|
|
48
|
+
"set_default_queue",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __getattr__(name: str) -> Any:
|
|
53
|
+
"""Import an adapter's module on first use, naming the extra to install if it is missing."""
|
|
54
|
+
if name not in _LAZY:
|
|
55
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
56
|
+
module, extra = _LAZY[name]
|
|
57
|
+
try:
|
|
58
|
+
value = getattr(import_module(f"{__name__}.{module}"), name)
|
|
59
|
+
except ImportError as exc:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
f"{name} needs the optional dependencies: uv add 'taskiq-llm-queue[{extra}]'"
|
|
62
|
+
) from exc
|
|
63
|
+
globals()[name] = value
|
|
64
|
+
return value
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The client interface the adapters call, and the process-wide default queue (D85).
|
|
2
|
+
|
|
3
|
+
Adapters type against `QueueClient` instead of importing `LLMQueue`, so they work with any object
|
|
4
|
+
that has the same methods (tests use a fake). Classes created at import time find the running
|
|
5
|
+
queue on first call through `get_default_queue`. This module never imports langchain or httpx.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from taskiq_llm_queue.errors import LLMQueueError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from taskiq_llm_queue.config import Lane
|
|
16
|
+
from taskiq_llm_queue.envelope import CallMeta, FallbackPolicy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConverseResultLike(Protocol):
|
|
20
|
+
"""What `converse` returns: the exact Bedrock Converse dict and how it was served."""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def response(self) -> dict[str, Any]: ...
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def meta(self) -> CallMeta: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChatResultLike(Protocol):
|
|
30
|
+
"""What `chat` returns: the OpenAI-compatible completion body and how it was served."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def response(self) -> dict[str, Any]: ...
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def meta(self) -> CallMeta: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EmbedResultLike(Protocol):
|
|
40
|
+
"""What `embed` returns: one vector per text, in input order, and how they were served."""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def vectors(self) -> list[list[float]]: ...
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def meta(self) -> CallMeta: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class QueueClient(Protocol):
|
|
51
|
+
"""The subset of `LLMQueue` the LangChain adapters and the HTTP transport use.
|
|
52
|
+
|
|
53
|
+
`lane=None` means "take the lane from `current_context()`"; adapters pass only explicit
|
|
54
|
+
overrides. Sync methods run on the caller's thread (direct path under the shared limiter).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
async def converse(
|
|
58
|
+
self,
|
|
59
|
+
model: str,
|
|
60
|
+
request: dict[str, Any],
|
|
61
|
+
*,
|
|
62
|
+
lane: Lane | str | None = None,
|
|
63
|
+
fallback: FallbackPolicy | None = None,
|
|
64
|
+
timeout_secs: float | None = None,
|
|
65
|
+
deadline_at: float | None = None,
|
|
66
|
+
request_id: str | None = None,
|
|
67
|
+
) -> ConverseResultLike: ...
|
|
68
|
+
|
|
69
|
+
def converse_sync(
|
|
70
|
+
self,
|
|
71
|
+
model: str,
|
|
72
|
+
request: dict[str, Any],
|
|
73
|
+
*,
|
|
74
|
+
lane: Lane | str | None = None,
|
|
75
|
+
fallback: FallbackPolicy | None = None,
|
|
76
|
+
timeout_secs: float | None = None,
|
|
77
|
+
deadline_at: float | None = None,
|
|
78
|
+
request_id: str | None = None,
|
|
79
|
+
) -> ConverseResultLike: ...
|
|
80
|
+
|
|
81
|
+
async def embed(
|
|
82
|
+
self,
|
|
83
|
+
model: str,
|
|
84
|
+
texts: list[str],
|
|
85
|
+
*,
|
|
86
|
+
lane: Lane | str | None = None,
|
|
87
|
+
input_type: str | None = None,
|
|
88
|
+
timeout_secs: float | None = None,
|
|
89
|
+
deadline_at: float | None = None,
|
|
90
|
+
request_id: str | None = None,
|
|
91
|
+
) -> EmbedResultLike: ...
|
|
92
|
+
|
|
93
|
+
def embed_sync(
|
|
94
|
+
self,
|
|
95
|
+
model: str,
|
|
96
|
+
texts: list[str],
|
|
97
|
+
*,
|
|
98
|
+
lane: Lane | str | None = None,
|
|
99
|
+
input_type: str | None = None,
|
|
100
|
+
timeout_secs: float | None = None,
|
|
101
|
+
deadline_at: float | None = None,
|
|
102
|
+
request_id: str | None = None,
|
|
103
|
+
) -> EmbedResultLike: ...
|
|
104
|
+
|
|
105
|
+
async def chat(
|
|
106
|
+
self,
|
|
107
|
+
model: str,
|
|
108
|
+
request: dict[str, Any],
|
|
109
|
+
*,
|
|
110
|
+
lane: Lane | str | None = None,
|
|
111
|
+
fallback: FallbackPolicy | None = None,
|
|
112
|
+
timeout_secs: float | None = None,
|
|
113
|
+
deadline_at: float | None = None,
|
|
114
|
+
request_id: str | None = None,
|
|
115
|
+
) -> ChatResultLike: ...
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_default_queue: QueueClient | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def set_default_queue(llmq: QueueClient | None) -> None:
|
|
122
|
+
"""Register the queue that adapters built without `llmq=` use; `None` clears it.
|
|
123
|
+
|
|
124
|
+
`LLMQueue.start()` is expected to call this, and `stop()` to clear it.
|
|
125
|
+
"""
|
|
126
|
+
global _default_queue
|
|
127
|
+
_default_queue = llmq
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_default_queue() -> QueueClient:
|
|
131
|
+
"""The registered default queue; raises `LLMQueueError` when none has been set yet."""
|
|
132
|
+
if _default_queue is None:
|
|
133
|
+
raise LLMQueueError(
|
|
134
|
+
"no LLM queue: pass llmq=... to the adapter or call "
|
|
135
|
+
"taskiq_llm_queue.langchain.set_default_queue(llmq) (LLMQueue.start() does this)"
|
|
136
|
+
)
|
|
137
|
+
return _default_queue
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_queue(explicit: Any) -> QueueClient:
|
|
141
|
+
"""The adapter's own queue if it has one, else the default, looked up on every call."""
|
|
142
|
+
return cast("QueueClient", explicit) if explicit is not None else get_default_queue()
|