taskiq-llm-queue 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. taskiq_llm_queue/__init__.py +138 -0
  2. taskiq_llm_queue/_logging.py +40 -0
  3. taskiq_llm_queue/_version.py +5 -0
  4. taskiq_llm_queue/broker/__init__.py +86 -0
  5. taskiq_llm_queue/broker/acker.py +239 -0
  6. taskiq_llm_queue/broker/batching.py +188 -0
  7. taskiq_llm_queue/broker/broker.py +937 -0
  8. taskiq_llm_queue/broker/delivery.py +50 -0
  9. taskiq_llm_queue/broker/errors.py +178 -0
  10. taskiq_llm_queue/broker/events.py +51 -0
  11. taskiq_llm_queue/broker/filters.py +32 -0
  12. taskiq_llm_queue/broker/heartbeat.py +157 -0
  13. taskiq_llm_queue/broker/queue.py +141 -0
  14. taskiq_llm_queue/broker/s3_offload.py +346 -0
  15. taskiq_llm_queue/broker/scheduler.py +113 -0
  16. taskiq_llm_queue/broker/serializer.py +32 -0
  17. taskiq_llm_queue/cli.py +488 -0
  18. taskiq_llm_queue/client.py +1409 -0
  19. taskiq_llm_queue/config.py +592 -0
  20. taskiq_llm_queue/context.py +62 -0
  21. taskiq_llm_queue/core.py +832 -0
  22. taskiq_llm_queue/envelope.py +136 -0
  23. taskiq_llm_queue/errors.py +71 -0
  24. taskiq_llm_queue/failover.py +286 -0
  25. taskiq_llm_queue/http_transport.py +220 -0
  26. taskiq_llm_queue/keys.py +87 -0
  27. taskiq_llm_queue/langchain/__init__.py +64 -0
  28. taskiq_llm_queue/langchain/_client.py +142 -0
  29. taskiq_llm_queue/langchain/bedrock.py +291 -0
  30. taskiq_llm_queue/langchain/openai.py +103 -0
  31. taskiq_llm_queue/lifecycle.py +512 -0
  32. taskiq_llm_queue/metrics/__init__.py +45 -0
  33. taskiq_llm_queue/metrics/emf.py +100 -0
  34. taskiq_llm_queue/metrics/prometheus.py +66 -0
  35. taskiq_llm_queue/metrics_names.py +19 -0
  36. taskiq_llm_queue/protocols.py +140 -0
  37. taskiq_llm_queue/providers/__init__.py +62 -0
  38. taskiq_llm_queue/providers/bedrock.py +188 -0
  39. taskiq_llm_queue/providers/classify.py +251 -0
  40. taskiq_llm_queue/providers/embeddings.py +87 -0
  41. taskiq_llm_queue/providers/hosts.py +34 -0
  42. taskiq_llm_queue/providers/openai_compat.py +142 -0
  43. taskiq_llm_queue/providers/registry.py +173 -0
  44. taskiq_llm_queue/py.typed +0 -0
  45. taskiq_llm_queue/reliability/__init__.py +59 -0
  46. taskiq_llm_queue/reliability/_lua.py +461 -0
  47. taskiq_llm_queue/reliability/breaker.py +86 -0
  48. taskiq_llm_queue/reliability/claims.py +226 -0
  49. taskiq_llm_queue/reliability/config_store.py +148 -0
  50. taskiq_llm_queue/reliability/fallback.py +111 -0
  51. taskiq_llm_queue/reliability/limiter.py +482 -0
  52. taskiq_llm_queue/reliability/local_limiter.py +153 -0
  53. taskiq_llm_queue/results/__init__.py +25 -0
  54. taskiq_llm_queue/results/backend.py +226 -0
  55. taskiq_llm_queue/results/dispatcher.py +366 -0
  56. taskiq_llm_queue/results/s3_backend.py +105 -0
  57. taskiq_llm_queue/routing.py +173 -0
  58. taskiq_llm_queue/routing_hooks.py +90 -0
  59. taskiq_llm_queue/tasks.py +453 -0
  60. taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
  61. taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
  62. taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
  63. taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
  64. taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  65. taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,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