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,251 @@
|
|
|
1
|
+
"""Map Bedrock and OpenAI SDK exceptions to `ErrorInfo` (throttle / retryable / fatal).
|
|
2
|
+
|
|
3
|
+
Decisions use error *families*, not exact class names or codes, because real services and
|
|
4
|
+
fakes spell the same failure differently (`ThrottlingException`, `Throttling`, `throttled`).
|
|
5
|
+
Used by `BedrockProvider.classify` and `OpenAICompatProvider.classify`; never imports an SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from taskiq_llm_queue.errors import ErrorInfo, ErrorKind
|
|
18
|
+
|
|
19
|
+
# Prefixes of normalised codes (lower-case letters only, "exception"/"error" suffix dropped).
|
|
20
|
+
_THROTTLE_PREFIXES = (
|
|
21
|
+
"throttl",
|
|
22
|
+
"toomanyrequests",
|
|
23
|
+
"modelnotready",
|
|
24
|
+
"servicequotaexceeded",
|
|
25
|
+
"requestlimitexceeded",
|
|
26
|
+
"provisionedthroughputexceeded",
|
|
27
|
+
"ratelimit",
|
|
28
|
+
"slowdown",
|
|
29
|
+
)
|
|
30
|
+
_RETRYABLE_PREFIXES = (
|
|
31
|
+
"serviceunavailable",
|
|
32
|
+
"internal",
|
|
33
|
+
"modeltimeout",
|
|
34
|
+
"modelerror",
|
|
35
|
+
"modelstreamerror",
|
|
36
|
+
"requesttimeout",
|
|
37
|
+
"timeout",
|
|
38
|
+
"serviceerror",
|
|
39
|
+
"unavailable",
|
|
40
|
+
)
|
|
41
|
+
_FATAL_PREFIXES = (
|
|
42
|
+
"validation",
|
|
43
|
+
"accessdenied",
|
|
44
|
+
"resourcenotfound",
|
|
45
|
+
"unrecognizedclient",
|
|
46
|
+
"notauthorized",
|
|
47
|
+
"unauthorized",
|
|
48
|
+
"expiredtoken",
|
|
49
|
+
"invalid",
|
|
50
|
+
"badrequest",
|
|
51
|
+
)
|
|
52
|
+
# Fatal families that name one target (model, profile, region, endpoint or the key for that
|
|
53
|
+
# URL), not the request: the next target may serve it. Credentials errors are not here.
|
|
54
|
+
_TARGET_FAMILIES = (
|
|
55
|
+
"resourcenotfound",
|
|
56
|
+
"accessdenied",
|
|
57
|
+
"permissiondenied",
|
|
58
|
+
"modelnotfound",
|
|
59
|
+
"notfound",
|
|
60
|
+
"endpointnotfound",
|
|
61
|
+
"unknownoperation",
|
|
62
|
+
)
|
|
63
|
+
# A validation / bad-request family counts as target-specific only when its message says so.
|
|
64
|
+
_TARGET_MESSAGE = re.compile(
|
|
65
|
+
r"model identifier is invalid|invalid model identifier|on-demand throughput"
|
|
66
|
+
r"|inference profile|model_not_found|unknown model|no such model|end of (?:its )?life"
|
|
67
|
+
r"|not (?:available|supported|enabled|accessible) (?:in|for) (?:this|the|your) region"
|
|
68
|
+
r"|\b(?:model|endpoint)\b[^.]{0,80}?\b(?:not found|does not exist|doesn't exist"
|
|
69
|
+
r"|not available|unavailable|is not supported|isn't supported)",
|
|
70
|
+
re.IGNORECASE,
|
|
71
|
+
)
|
|
72
|
+
# Request-fatal however the code is spelled: a different target would refuse it the same way.
|
|
73
|
+
_REQUEST_MESSAGE = re.compile(
|
|
74
|
+
r"too long|too large|too many (?:input )?tokens|context (?:length|window)"
|
|
75
|
+
r"|maximum (?:input|context|token)|malformed",
|
|
76
|
+
re.IGNORECASE,
|
|
77
|
+
)
|
|
78
|
+
# Built lazily from whichever modules are already imported; an SDK that was never imported
|
|
79
|
+
# cannot have raised the exception being classified.
|
|
80
|
+
_TRANSPORT_TYPES: dict[str, tuple[str, ...]] = {
|
|
81
|
+
"botocore.exceptions": (
|
|
82
|
+
"ReadTimeoutError",
|
|
83
|
+
"ConnectTimeoutError",
|
|
84
|
+
"EndpointConnectionError",
|
|
85
|
+
"ConnectionClosedError",
|
|
86
|
+
"HTTPClientError",
|
|
87
|
+
),
|
|
88
|
+
"aiohttp": ("ClientError",),
|
|
89
|
+
"httpx": ("TransportError",),
|
|
90
|
+
"openai": ("APITimeoutError", "APIConnectionError"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def error_family(code: str) -> str:
|
|
95
|
+
"""Normalise a provider error code: letters only, lower case, no exception suffix.
|
|
96
|
+
|
|
97
|
+
`ThrottlingException`, `throttling_exception` and `Throttling` all become `throttling`.
|
|
98
|
+
"""
|
|
99
|
+
family = re.sub(r"[^a-z]", "", code.lower())
|
|
100
|
+
for suffix in ("exception", "error", "fault"):
|
|
101
|
+
if family.endswith(suffix) and len(family) > len(suffix):
|
|
102
|
+
return family[: -len(suffix)]
|
|
103
|
+
return family
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def kind_for_code(code: str, status: int | None = None) -> ErrorKind | None:
|
|
107
|
+
"""Classify a provider error code by family, falling back to the HTTP status.
|
|
108
|
+
|
|
109
|
+
Returns None when neither the code nor the status says anything.
|
|
110
|
+
"""
|
|
111
|
+
family = error_family(code)
|
|
112
|
+
if family:
|
|
113
|
+
for prefixes, kind in (
|
|
114
|
+
(_THROTTLE_PREFIXES, ErrorKind.THROTTLE),
|
|
115
|
+
(_RETRYABLE_PREFIXES, ErrorKind.RETRYABLE),
|
|
116
|
+
(_FATAL_PREFIXES, ErrorKind.FATAL),
|
|
117
|
+
):
|
|
118
|
+
if family.startswith(prefixes):
|
|
119
|
+
return kind
|
|
120
|
+
return kind_for_status(status)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def kind_for_status(status: int | None) -> ErrorKind | None:
|
|
124
|
+
"""Classify by HTTP status alone: 429 throttle, 408/409/5xx retryable, other 4xx fatal."""
|
|
125
|
+
if status is None:
|
|
126
|
+
return None
|
|
127
|
+
if status == 429:
|
|
128
|
+
return ErrorKind.THROTTLE
|
|
129
|
+
if status in (408, 409) or status >= 500:
|
|
130
|
+
return ErrorKind.RETRYABLE
|
|
131
|
+
if 400 <= status < 500:
|
|
132
|
+
return ErrorKind.FATAL
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_retry_after(
|
|
137
|
+
headers: Mapping[str, Any] | None, *, now: float | None = None
|
|
138
|
+
) -> float | None:
|
|
139
|
+
"""Seconds to wait from `retry-after-ms`, else `Retry-After` (seconds or an HTTP date).
|
|
140
|
+
|
|
141
|
+
Header names are matched case-blind; unparsable values are ignored and past dates give 0.
|
|
142
|
+
"""
|
|
143
|
+
if not headers:
|
|
144
|
+
return None
|
|
145
|
+
lowered = {str(k).lower(): str(v).strip() for k, v in headers.items()}
|
|
146
|
+
if (raw_ms := lowered.get("retry-after-ms")) is not None:
|
|
147
|
+
try:
|
|
148
|
+
return max(float(raw_ms) / 1000.0, 0.0)
|
|
149
|
+
except ValueError:
|
|
150
|
+
pass
|
|
151
|
+
raw = lowered.get("retry-after")
|
|
152
|
+
if raw is None:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
return max(float(raw), 0.0)
|
|
156
|
+
except ValueError:
|
|
157
|
+
pass
|
|
158
|
+
try:
|
|
159
|
+
when = parsedate_to_datetime(raw)
|
|
160
|
+
except (TypeError, ValueError, IndexError):
|
|
161
|
+
return None
|
|
162
|
+
current = time.time() if now is None else now
|
|
163
|
+
return max(when.timestamp() - current, 0.0)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def is_transport_error(exc: BaseException) -> bool:
|
|
167
|
+
"""True for timeouts and connection failures from asyncio, botocore, aiohttp, httpx, openai."""
|
|
168
|
+
if isinstance(exc, TimeoutError | ConnectionError):
|
|
169
|
+
return True
|
|
170
|
+
for module_name, names in _TRANSPORT_TYPES.items():
|
|
171
|
+
module = sys.modules.get(module_name)
|
|
172
|
+
if module is None:
|
|
173
|
+
continue
|
|
174
|
+
types = tuple(t for n in names if isinstance(t := getattr(module, n, None), type))
|
|
175
|
+
if types and isinstance(exc, types):
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def is_target_error(exc: BaseException, info: ErrorInfo) -> bool:
|
|
181
|
+
"""True when a FATAL error is about the target, so `run_call` tries the next fallback step.
|
|
182
|
+
|
|
183
|
+
Model or profile not found / not in this region, access denied for the model, unknown
|
|
184
|
+
endpoint (404). Bad payloads, too-long input and credentials errors end the call.
|
|
185
|
+
"""
|
|
186
|
+
if info.kind is not ErrorKind.FATAL:
|
|
187
|
+
return False
|
|
188
|
+
message = _error_message(exc)
|
|
189
|
+
if _REQUEST_MESSAGE.search(message):
|
|
190
|
+
return False
|
|
191
|
+
family = error_family(info.code)
|
|
192
|
+
if family.startswith(_TARGET_FAMILIES) or _error_status(exc) == 404:
|
|
193
|
+
return True
|
|
194
|
+
request_family = family.startswith(("validation", "badrequest", "invalid"))
|
|
195
|
+
return (request_family or _error_status(exc) == 400) and bool(_TARGET_MESSAGE.search(message))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _error_message(exc: BaseException) -> str:
|
|
199
|
+
"""A botocore `Error.Message`, else the exception text (openai puts the body there)."""
|
|
200
|
+
response = getattr(exc, "response", None)
|
|
201
|
+
if isinstance(response, Mapping) and isinstance(response.get("Error"), Mapping):
|
|
202
|
+
return str(response["Error"].get("Message") or "")
|
|
203
|
+
return str(exc)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _error_status(exc: BaseException) -> int | None:
|
|
207
|
+
"""HTTP status of a botocore `ClientError` or an openai `APIStatusError`, if any."""
|
|
208
|
+
status = getattr(exc, "status_code", None)
|
|
209
|
+
response = getattr(exc, "response", None)
|
|
210
|
+
if status is None and isinstance(response, Mapping):
|
|
211
|
+
status = (response.get("ResponseMetadata") or {}).get("HTTPStatusCode")
|
|
212
|
+
return status if isinstance(status, int) else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def classify_bedrock(exc: BaseException) -> ErrorInfo:
|
|
216
|
+
"""Classify a botocore `ClientError` (or transport error) from a Bedrock runtime call.
|
|
217
|
+
|
|
218
|
+
Unknown codes fall back to the HTTP status; anything else that is not a transport error is
|
|
219
|
+
fatal, so a bug in the request never loops through retries.
|
|
220
|
+
"""
|
|
221
|
+
response = getattr(exc, "response", None)
|
|
222
|
+
if isinstance(response, Mapping) and isinstance(response.get("Error"), Mapping):
|
|
223
|
+
code = str(response["Error"].get("Code") or "")
|
|
224
|
+
meta = response.get("ResponseMetadata") or {}
|
|
225
|
+
status = meta.get("HTTPStatusCode")
|
|
226
|
+
kind = kind_for_code(code, status if isinstance(status, int) else None)
|
|
227
|
+
retry_after = parse_retry_after(meta.get("HTTPHeaders"))
|
|
228
|
+
return ErrorInfo(kind=kind or ErrorKind.FATAL, code=code, retry_after_secs=retry_after)
|
|
229
|
+
if is_transport_error(exc):
|
|
230
|
+
return ErrorInfo(kind=ErrorKind.RETRYABLE, code=type(exc).__name__)
|
|
231
|
+
return ErrorInfo(kind=ErrorKind.FATAL, code=type(exc).__name__)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def classify_openai(exc: BaseException) -> ErrorInfo:
|
|
235
|
+
"""Classify an openai SDK exception (or transport error) from an OpenAI-compatible server.
|
|
236
|
+
|
|
237
|
+
429 is a throttle carrying the server's advised wait, except `insufficient_quota`, which
|
|
238
|
+
no wait fixes. 408/409/5xx, timeouts and connection errors are retryable; other 4xx fatal.
|
|
239
|
+
"""
|
|
240
|
+
if is_transport_error(exc):
|
|
241
|
+
return ErrorInfo(kind=ErrorKind.RETRYABLE, code=type(exc).__name__)
|
|
242
|
+
status = getattr(exc, "status_code", None)
|
|
243
|
+
if not isinstance(status, int):
|
|
244
|
+
return ErrorInfo(kind=ErrorKind.FATAL, code=type(exc).__name__)
|
|
245
|
+
code = str(getattr(exc, "code", None) or getattr(exc, "type", None) or type(exc).__name__)
|
|
246
|
+
response = getattr(exc, "response", None)
|
|
247
|
+
retry_after = parse_retry_after(getattr(response, "headers", None))
|
|
248
|
+
kind = kind_for_status(status) or ErrorKind.FATAL
|
|
249
|
+
if kind is ErrorKind.THROTTLE and code == "insufficient_quota":
|
|
250
|
+
kind = ErrorKind.FATAL
|
|
251
|
+
return ErrorInfo(kind=kind, code=code, retry_after_secs=retry_after)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Embedding batching limits and Bedrock request-body builders (Titan, Cohere).
|
|
2
|
+
|
|
3
|
+
An embed call is split into tasks of at most `max_batch_size` texts: Titan takes one
|
|
4
|
+
text per request, Cohere v3 up to 96, OpenAI `chunk_size` (2048 by default).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
10
|
+
from typing import Any, Literal, TypeVar
|
|
11
|
+
|
|
12
|
+
_T = TypeVar("_T") # PEP 695 syntax needs 3.12; the package supports 3.11
|
|
13
|
+
|
|
14
|
+
TITAN_MAX_BATCH = 1
|
|
15
|
+
COHERE_MAX_BATCH = 96
|
|
16
|
+
OPENAI_DEFAULT_CHUNK_SIZE = 2048
|
|
17
|
+
|
|
18
|
+
CohereInputType = Literal["search_document", "search_query", "classification", "clustering"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def max_batch_size(provider: str, model_id: str, *, chunk_size: int | None = None) -> int:
|
|
22
|
+
"""Largest number of texts one embedding request may carry for this provider and model.
|
|
23
|
+
|
|
24
|
+
Bedrock models other than Cohere are treated like Titan (one text per request).
|
|
25
|
+
"""
|
|
26
|
+
if provider == "openai":
|
|
27
|
+
return chunk_size or OPENAI_DEFAULT_CHUNK_SIZE
|
|
28
|
+
return COHERE_MAX_BATCH if "cohere" in model_id.lower() else TITAN_MAX_BATCH
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def batches(items: Sequence[_T], size: int) -> Iterator[list[_T]]:
|
|
32
|
+
"""Yield consecutive slices of at most `size` items, preserving order."""
|
|
33
|
+
if size < 1:
|
|
34
|
+
raise ValueError("batch size must be >= 1")
|
|
35
|
+
for start in range(0, len(items), size):
|
|
36
|
+
yield list(items[start : start + size])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def titan_body(
|
|
40
|
+
text: str, *, dimensions: int | None = None, normalize: bool | None = None
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""InvokeModel body for Amazon Titan text embeddings (v2 accepts dimensions/normalize)."""
|
|
43
|
+
body: dict[str, Any] = {"inputText": text}
|
|
44
|
+
if dimensions is not None:
|
|
45
|
+
body["dimensions"] = dimensions
|
|
46
|
+
if normalize is not None:
|
|
47
|
+
body["normalize"] = normalize
|
|
48
|
+
return body
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cohere_body(
|
|
52
|
+
texts: Sequence[str],
|
|
53
|
+
*,
|
|
54
|
+
input_type: CohereInputType = "search_document",
|
|
55
|
+
truncate: Literal["NONE", "START", "END"] | None = None,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""InvokeModel body for Cohere embed v3 on Bedrock (at most 96 texts)."""
|
|
58
|
+
if len(texts) > COHERE_MAX_BATCH:
|
|
59
|
+
raise ValueError(f"Cohere takes at most {COHERE_MAX_BATCH} texts, got {len(texts)}")
|
|
60
|
+
body: dict[str, Any] = {"texts": list(texts), "input_type": input_type}
|
|
61
|
+
if truncate is not None:
|
|
62
|
+
body["truncate"] = truncate
|
|
63
|
+
return body
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def embedding_body(model_id: str, texts: Sequence[str], **options: Any) -> dict[str, Any]:
|
|
67
|
+
"""Pick the Titan or Cohere body builder by model id; Titan takes exactly one text."""
|
|
68
|
+
if "cohere" in model_id.lower():
|
|
69
|
+
return cohere_body(texts, **options)
|
|
70
|
+
if len(texts) != TITAN_MAX_BATCH:
|
|
71
|
+
raise ValueError(f"Titan takes exactly one text per request, got {len(texts)}")
|
|
72
|
+
return titan_body(texts[0], **options)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_embeddings(decoded: Mapping[str, Any]) -> list[list[float]]:
|
|
76
|
+
"""Vectors from a decoded Titan (`embedding`) or Cohere (`embeddings`) response body.
|
|
77
|
+
|
|
78
|
+
Cohere's typed form (`{"embeddings": {"float": [...]}}`) is accepted too.
|
|
79
|
+
"""
|
|
80
|
+
if "embedding" in decoded:
|
|
81
|
+
return [list(decoded["embedding"])]
|
|
82
|
+
vectors = decoded.get("embeddings")
|
|
83
|
+
if isinstance(vectors, Mapping):
|
|
84
|
+
vectors = vectors.get("float")
|
|
85
|
+
if not isinstance(vectors, list):
|
|
86
|
+
raise ValueError("response has no 'embedding' or 'embeddings'")
|
|
87
|
+
return [list(v) for v in vectors]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Host deny-list checks for provider endpoints (`QueueConfig.denied_hosts`).
|
|
2
|
+
|
|
3
|
+
Patterns are shell globs on the URL's host name (`*.openai.azure.com`), matched case-blind.
|
|
4
|
+
Providers run the check before they create a client, so a denied endpoint is never contacted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from fnmatch import fnmatchcase
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from taskiq_llm_queue.errors import ConfigError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def denied_pattern(url: str, denied_hosts: Iterable[str]) -> str | None:
|
|
17
|
+
"""Return the first deny pattern matching `url`'s host, or None when the host is allowed.
|
|
18
|
+
|
|
19
|
+
A bare host (`api.example.com`) is accepted as well as a full URL.
|
|
20
|
+
"""
|
|
21
|
+
host = (urlsplit(url if "//" in url else f"//{url}").hostname or "").lower()
|
|
22
|
+
for pattern in denied_hosts:
|
|
23
|
+
if host and fnmatchcase(host, pattern.strip().lower()):
|
|
24
|
+
return pattern
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_url(url: str | None, denied_hosts: Iterable[str]) -> None:
|
|
29
|
+
"""Raise `ConfigError` when `url` points at a denied host; None (SDK default) passes."""
|
|
30
|
+
if url is None:
|
|
31
|
+
return
|
|
32
|
+
pattern = denied_pattern(url, denied_hosts)
|
|
33
|
+
if pattern is not None:
|
|
34
|
+
raise ConfigError(f"endpoint {url!r} matches denied host pattern {pattern!r}")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""OpenAI-compatible provider: chat completions and embeddings against any base URL.
|
|
2
|
+
|
|
3
|
+
Request bodies are OpenAI-native and pass through unchanged (streaming forced off). SDK
|
|
4
|
+
retries are off (`max_retries=0`) so a 429 and its `Retry-After` reach the limiter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from taskiq_llm_queue.errors import ConfigError, ErrorInfo
|
|
14
|
+
from taskiq_llm_queue.providers.classify import classify_openai
|
|
15
|
+
from taskiq_llm_queue.providers.hosts import check_url
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from openai import AsyncOpenAI
|
|
19
|
+
|
|
20
|
+
from taskiq_llm_queue.config import QueueConfig, Target
|
|
21
|
+
from taskiq_llm_queue.envelope import Op
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT_SECS = 300.0 # unverified; long completions on slow self-hosted servers
|
|
24
|
+
DEFAULT_CONNECT_TIMEOUT_SECS = 10.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OpenAICompatProvider:
|
|
28
|
+
"""`Provider` for OpenAI-compatible servers: ops `chat` and `embeddings`.
|
|
29
|
+
|
|
30
|
+
One `AsyncOpenAI` client per base URL (`Target.base_url`, else the provider default),
|
|
31
|
+
each with the API key configured for that URL. Responses are `model_dump()` dicts.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name = "openai"
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str | None = None,
|
|
40
|
+
api_keys: Mapping[str | None, str | None] | None = None,
|
|
41
|
+
denied_hosts: Iterable[str] = (),
|
|
42
|
+
timeout_secs: float = DEFAULT_TIMEOUT_SECS,
|
|
43
|
+
connect_timeout_secs: float = DEFAULT_CONNECT_TIMEOUT_SECS,
|
|
44
|
+
client_kwargs: Mapping[str, Any] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Keep settings only; the openai SDK is imported on the first call.
|
|
47
|
+
|
|
48
|
+
`api_keys` maps a base URL (None = the default URL) to its key; a missing key lets
|
|
49
|
+
the SDK fall back to `OPENAI_API_KEY`. `timeout_secs` is the httpx read (and write /
|
|
50
|
+
pool) timeout, `connect_timeout_secs` its connect timeout.
|
|
51
|
+
"""
|
|
52
|
+
self._denied_hosts = tuple(denied_hosts)
|
|
53
|
+
check_url(base_url, self._denied_hosts)
|
|
54
|
+
self._base_url = base_url
|
|
55
|
+
self._api_keys = dict(api_keys or {})
|
|
56
|
+
self._timeout_secs = timeout_secs
|
|
57
|
+
self._connect_timeout_secs = connect_timeout_secs
|
|
58
|
+
self._client_kwargs = dict(client_kwargs or {})
|
|
59
|
+
self._clients: dict[str | None, AsyncOpenAI] = {}
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_config(
|
|
63
|
+
cls, config: QueueConfig, *, environ: Mapping[str, str] | None = None, **kwargs: Any
|
|
64
|
+
) -> OpenAICompatProvider:
|
|
65
|
+
"""Build from `QueueConfig`: deny-list every OpenAI target and read keys at boot.
|
|
66
|
+
|
|
67
|
+
Each target's `base_url` gets its model's `api_key_env`. A missing variable or two
|
|
68
|
+
models sharing a URL with different key variables is a `ConfigError` (all reported).
|
|
69
|
+
"""
|
|
70
|
+
env = os.environ if environ is None else environ
|
|
71
|
+
key_envs: dict[str | None, str | None] = {}
|
|
72
|
+
errors: list[str] = []
|
|
73
|
+
for alias, model in config.models.items():
|
|
74
|
+
if model.provider != "openai":
|
|
75
|
+
continue
|
|
76
|
+
for target in model.targets.values():
|
|
77
|
+
check_url(target.base_url, config.denied_hosts)
|
|
78
|
+
url = target.base_url
|
|
79
|
+
if url in key_envs and key_envs[url] != model.api_key_env:
|
|
80
|
+
errors.append(f"{alias}: base_url {url!r} already uses {key_envs[url]!r}")
|
|
81
|
+
key_envs.setdefault(url, model.api_key_env)
|
|
82
|
+
api_keys: dict[str | None, str | None] = {}
|
|
83
|
+
for url, name in key_envs.items():
|
|
84
|
+
if name is None:
|
|
85
|
+
continue
|
|
86
|
+
if not env.get(name):
|
|
87
|
+
errors.append(f"api_key_env {name!r} (for {url or 'default URL'}) is not set")
|
|
88
|
+
api_keys[url] = env.get(name)
|
|
89
|
+
if errors:
|
|
90
|
+
raise ConfigError("; ".join(errors))
|
|
91
|
+
kwargs.setdefault("timeout_secs", config.provider_read_timeout_secs)
|
|
92
|
+
kwargs.setdefault("connect_timeout_secs", config.provider_connect_timeout_secs)
|
|
93
|
+
return cls(api_keys=api_keys, denied_hosts=config.denied_hosts, **kwargs)
|
|
94
|
+
|
|
95
|
+
async def call(self, op: Op, params: Mapping[str, Any], target: Target) -> dict[str, Any]:
|
|
96
|
+
"""Run one chat-completions or embeddings attempt and return the response as a dict."""
|
|
97
|
+
client = self._client(target.base_url)
|
|
98
|
+
if op == "chat":
|
|
99
|
+
response: Any = await client.chat.completions.create(**{**params, "stream": False})
|
|
100
|
+
elif op == "embeddings":
|
|
101
|
+
response = await client.embeddings.create(**params)
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError(f"OpenAI-compatible provider does not support op {op!r}")
|
|
104
|
+
result: dict[str, Any] = response.model_dump(mode="json", exclude_unset=True)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
def classify(self, exc: BaseException) -> ErrorInfo:
|
|
108
|
+
"""Throttle (with Retry-After) / retryable / fatal by status (see `classify_openai`)."""
|
|
109
|
+
return classify_openai(exc)
|
|
110
|
+
|
|
111
|
+
async def aclose(self) -> None:
|
|
112
|
+
"""Close every client this provider created; safe to call more than once."""
|
|
113
|
+
clients = list(self._clients.values())
|
|
114
|
+
self._clients.clear()
|
|
115
|
+
for client in clients:
|
|
116
|
+
await client.close()
|
|
117
|
+
|
|
118
|
+
def _client(self, target_url: str | None) -> AsyncOpenAI:
|
|
119
|
+
"""Return the cached client for a target URL (None = default), deny-listing it first.
|
|
120
|
+
|
|
121
|
+
Keys are looked up by the target URL only, so one host's key never goes to another.
|
|
122
|
+
"""
|
|
123
|
+
if (client := self._clients.get(target_url)) is not None:
|
|
124
|
+
return client
|
|
125
|
+
base_url = target_url or self._base_url
|
|
126
|
+
check_url(base_url, self._denied_hosts)
|
|
127
|
+
try:
|
|
128
|
+
import httpx
|
|
129
|
+
from openai import AsyncOpenAI
|
|
130
|
+
except ImportError as exc:
|
|
131
|
+
raise ConfigError(
|
|
132
|
+
"OpenAICompatProvider needs the openai SDK: pip install 'taskiq-llm-queue[openai]'"
|
|
133
|
+
) from exc
|
|
134
|
+
client = AsyncOpenAI(
|
|
135
|
+
api_key=self._api_keys.get(target_url),
|
|
136
|
+
base_url=base_url,
|
|
137
|
+
max_retries=0,
|
|
138
|
+
timeout=httpx.Timeout(self._timeout_secs, connect=self._connect_timeout_secs),
|
|
139
|
+
**self._client_kwargs,
|
|
140
|
+
)
|
|
141
|
+
self._clients[target_url] = client
|
|
142
|
+
return client
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Provider registry: built-ins, import-path overrides and entry-point plugins.
|
|
2
|
+
|
|
3
|
+
Resolution order per provider name: `QueueConfig.providers` (`"pkg.mod:Class"`), then the
|
|
4
|
+
`taskiq_llm_queue.providers` entry-point group, then the built-ins. Every check
|
|
5
|
+
(`allowed_providers`, `denied_hosts`, importability) runs at construction, i.e. at boot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from importlib.metadata import entry_points
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from taskiq_llm_queue.errors import ConfigError
|
|
17
|
+
from taskiq_llm_queue.protocols import Provider
|
|
18
|
+
from taskiq_llm_queue.providers.hosts import denied_pattern
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from aiobotocore.session import AioSession
|
|
22
|
+
|
|
23
|
+
from taskiq_llm_queue.config import QueueConfig
|
|
24
|
+
|
|
25
|
+
ENTRY_POINT_GROUP = "taskiq_llm_queue.providers"
|
|
26
|
+
BUILTIN_PROVIDERS: Mapping[str, str] = {
|
|
27
|
+
"bedrock": "taskiq_llm_queue.providers.bedrock:BedrockProvider",
|
|
28
|
+
"openai": "taskiq_llm_queue.providers.openai_compat:OpenAICompatProvider",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ProviderFactory = Callable[["QueueConfig"], Provider]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_object(path: str) -> Any:
|
|
35
|
+
"""Import `"pkg.mod:attr.sub"` (or `"pkg.mod.attr"`); failures raise `ConfigError`."""
|
|
36
|
+
module_name, sep, attr_path = path.partition(":")
|
|
37
|
+
if not sep:
|
|
38
|
+
module_name, _, attr_path = path.rpartition(".")
|
|
39
|
+
if not module_name or not attr_path:
|
|
40
|
+
raise ConfigError(f"invalid import path {path!r}; expected 'pkg.module:Name'")
|
|
41
|
+
try:
|
|
42
|
+
obj: Any = importlib.import_module(module_name)
|
|
43
|
+
for attr in attr_path.split("."):
|
|
44
|
+
obj = getattr(obj, attr)
|
|
45
|
+
except (ImportError, AttributeError) as exc:
|
|
46
|
+
raise ConfigError(f"cannot load provider {path!r}: {exc}") from exc
|
|
47
|
+
return obj
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def as_factory(obj: Any) -> ProviderFactory:
|
|
51
|
+
"""Turn a provider class or callable into a factory taking `QueueConfig`.
|
|
52
|
+
|
|
53
|
+
Classes with a `from_config` classmethod use it; anything else is called with the config.
|
|
54
|
+
"""
|
|
55
|
+
from_config = getattr(obj, "from_config", None)
|
|
56
|
+
factory = from_config if callable(from_config) else obj
|
|
57
|
+
if not callable(factory):
|
|
58
|
+
raise ConfigError(f"provider {obj!r} is not callable")
|
|
59
|
+
return factory # type: ignore[no-any-return]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProviderRegistry:
|
|
63
|
+
"""Builds and caches one `Provider` per name for a `QueueConfig`, enforcing policy.
|
|
64
|
+
|
|
65
|
+
Used by workers to find the provider for a model alias; `aclose()` closes them all.
|
|
66
|
+
`factories` injects ready factories. `aws_session` (set by `LLMQueue.start`) goes to every
|
|
67
|
+
factory with a `session` keyword, so AWS providers share one credential chain.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
config: QueueConfig,
|
|
73
|
+
*,
|
|
74
|
+
factories: Mapping[str, ProviderFactory] | None = None,
|
|
75
|
+
use_entry_points: bool = True,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Validate every provider the config needs and resolve its factory; collect errors."""
|
|
78
|
+
self._config = config
|
|
79
|
+
self.aws_session: AioSession | None = None
|
|
80
|
+
self._providers: dict[str, Provider] = {}
|
|
81
|
+
self._factories: dict[str, ProviderFactory] = {}
|
|
82
|
+
injected = dict(factories or {})
|
|
83
|
+
plugins = (
|
|
84
|
+
{ep.name: ep for ep in entry_points(group=ENTRY_POINT_GROUP)}
|
|
85
|
+
if use_entry_points
|
|
86
|
+
else {}
|
|
87
|
+
)
|
|
88
|
+
errors: list[str] = []
|
|
89
|
+
names = {m.provider for m in config.models.values()} | set(config.providers)
|
|
90
|
+
for name in sorted(names):
|
|
91
|
+
if name not in config.allowed_providers:
|
|
92
|
+
errors.append(f"provider {name!r} is not in allowed_providers")
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
if name in injected:
|
|
96
|
+
self._factories[name] = injected[name]
|
|
97
|
+
elif name in config.providers:
|
|
98
|
+
self._factories[name] = as_factory(load_object(config.providers[name]))
|
|
99
|
+
elif name in plugins:
|
|
100
|
+
self._factories[name] = as_factory(plugins[name].load())
|
|
101
|
+
elif name in BUILTIN_PROVIDERS:
|
|
102
|
+
self._factories[name] = as_factory(load_object(BUILTIN_PROVIDERS[name]))
|
|
103
|
+
else:
|
|
104
|
+
errors.append(f"no provider registered under {name!r}")
|
|
105
|
+
except ConfigError as exc:
|
|
106
|
+
errors.append(str(exc))
|
|
107
|
+
except Exception as exc: # a broken plugin must fail boot with its name
|
|
108
|
+
errors.append(f"cannot load provider {name!r}: {exc!r}")
|
|
109
|
+
errors.extend(_host_errors(config))
|
|
110
|
+
if errors:
|
|
111
|
+
raise ConfigError("; ".join(errors))
|
|
112
|
+
|
|
113
|
+
def get(self, name: str) -> Provider:
|
|
114
|
+
"""Return the provider registered under `name`, building it on first use."""
|
|
115
|
+
if (provider := self._providers.get(name)) is not None:
|
|
116
|
+
return provider
|
|
117
|
+
factory = self._factories.get(name)
|
|
118
|
+
if factory is None:
|
|
119
|
+
raise ConfigError(f"provider {name!r} is not configured or not allowed")
|
|
120
|
+
try:
|
|
121
|
+
if self.aws_session is not None and _takes_session(factory):
|
|
122
|
+
provider = factory(self._config, session=self.aws_session) # type: ignore[call-arg] # checked by _takes_session
|
|
123
|
+
else:
|
|
124
|
+
provider = factory(self._config)
|
|
125
|
+
except ConfigError:
|
|
126
|
+
raise
|
|
127
|
+
except Exception as exc: # name the provider: a bare TypeError from a plugin is opaque
|
|
128
|
+
raise ConfigError(f"provider {name!r} failed to build: {exc!r}") from exc
|
|
129
|
+
if not isinstance(provider, Provider):
|
|
130
|
+
raise ConfigError(f"provider {name!r} does not implement the Provider protocol")
|
|
131
|
+
self._providers[name] = provider
|
|
132
|
+
return provider
|
|
133
|
+
|
|
134
|
+
def for_model(self, model: str) -> Provider:
|
|
135
|
+
"""Return the provider serving `alias[@target]`."""
|
|
136
|
+
_, cfg, _ = self._config.resolve(model)
|
|
137
|
+
return self.get(cfg.provider)
|
|
138
|
+
|
|
139
|
+
async def aclose(self) -> None:
|
|
140
|
+
"""Close every built provider that has an `aclose()`; safe to call more than once."""
|
|
141
|
+
providers = list(self._providers.values())
|
|
142
|
+
self._providers.clear()
|
|
143
|
+
for provider in providers:
|
|
144
|
+
close = getattr(provider, "aclose", None)
|
|
145
|
+
if callable(close):
|
|
146
|
+
await close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _takes_session(factory: ProviderFactory) -> bool:
|
|
150
|
+
"""True when the factory names a `session` keyword (e.g. `BedrockProvider.from_config`)."""
|
|
151
|
+
try:
|
|
152
|
+
parameters = inspect.signature(factory).parameters
|
|
153
|
+
except (TypeError, ValueError): # callables without a signature
|
|
154
|
+
return False
|
|
155
|
+
param = parameters.get("session")
|
|
156
|
+
return param is not None and param.kind in (param.KEYWORD_ONLY, param.POSITIONAL_OR_KEYWORD)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _host_errors(config: QueueConfig) -> list[str]:
|
|
160
|
+
"""Every configured endpoint (model targets, Bedrock endpoint) that hits `denied_hosts`."""
|
|
161
|
+
if not config.denied_hosts:
|
|
162
|
+
return []
|
|
163
|
+
urls = [
|
|
164
|
+
(f"{alias}@{name}", t.base_url)
|
|
165
|
+
for alias, m in config.models.items()
|
|
166
|
+
for name, t in m.targets.items()
|
|
167
|
+
]
|
|
168
|
+
urls.append(("bedrock_endpoint_url", config.bedrock_endpoint_url))
|
|
169
|
+
errors = []
|
|
170
|
+
for where, url in urls:
|
|
171
|
+
if url and (pattern := denied_pattern(url, config.denied_hosts)) is not None:
|
|
172
|
+
errors.append(f"{where}: endpoint {url!r} matches denied host {pattern!r}")
|
|
173
|
+
return errors
|