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,291 @@
1
+ """Queued Bedrock adapters: `ChatBedrockConverse` and `BedrockEmbeddings` that call the LLM queue.
2
+
3
+ The chat class runs the parent's own `_generate` against a placeholder client that hands the
4
+ Converse request to the queue, so messages, tools, cache points, reasoning and usage are built
5
+ and parsed exactly as the installed langchain-aws does (0.2 and 1.x lines alike). No boto3
6
+ client is ever created: both classes get a placeholder client that refuses direct calls.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from collections.abc import Callable
13
+ from contextvars import ContextVar
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from langchain_aws import BedrockEmbeddings, ChatBedrockConverse
17
+ from pydantic import Field, model_validator
18
+
19
+ from taskiq_llm_queue.config import Lane
20
+ from taskiq_llm_queue.envelope import FallbackPolicy
21
+ from taskiq_llm_queue.errors import LLMQueueError
22
+ from taskiq_llm_queue.langchain._client import resolve_queue
23
+
24
+ if TYPE_CHECKING:
25
+ from langchain_core.callbacks import (
26
+ AsyncCallbackManagerForLLMRun,
27
+ CallbackManagerForLLMRun,
28
+ )
29
+ from langchain_core.messages import BaseMessage
30
+ from langchain_core.outputs import ChatResult
31
+
32
+ from taskiq_llm_queue.langchain._client import ConverseResultLike, QueueClient
33
+
34
+ # Per-call Converse handler of the placeholder client (a ContextVar: concurrent calls on one
35
+ # model instance never see each other's handler).
36
+ _Handler = Callable[[dict[str, Any]], dict[str, Any]]
37
+ _HANDLER: ContextVar[_Handler | None] = ContextVar("llmq_converse_handler", default=None)
38
+
39
+
40
+ class _Captured(Exception): # control flow, not an error
41
+ """Raised by the capturing handler to stop the parent right after it built the request."""
42
+
43
+ def __init__(self, request: dict[str, Any]) -> None:
44
+ super().__init__("captured")
45
+ self.request = request
46
+
47
+
48
+ class _QueueOnlyClient:
49
+ """Placeholder for the boto3 client: stops the parent creating one and refuses direct calls.
50
+
51
+ `converse` answers only inside a queued call (through the current handler); any other path
52
+ that would reach Bedrock directly (e.g. a parent `_stream`) fails loudly.
53
+ """
54
+
55
+ def converse(self, **request: Any) -> dict[str, Any]:
56
+ handler = _HANDLER.get()
57
+ if handler is None:
58
+ self._refuse()
59
+ assert handler is not None
60
+ return handler(request)
61
+
62
+ def _refuse(self, *_args: Any, **_kwargs: Any) -> Any:
63
+ raise LLMQueueError("this model sends every call through the LLM queue")
64
+
65
+ converse_stream = invoke_model = get_inference_profile = _refuse
66
+
67
+
68
+ def _queue_only_values(values: Any, *slots: str) -> Any:
69
+ """Fill the given boto3 client slots with the placeholder unless the caller supplied them."""
70
+ if isinstance(values, dict):
71
+ for slot in slots:
72
+ values.setdefault(slot, _QueueOnlyClient())
73
+ return values
74
+
75
+
76
+ def _looks_like_bedrock_id(model_id: str) -> bool:
77
+ """Bedrock ids are `provider.model[...]` (optionally region-prefixed) or ARNs."""
78
+ return "." in model_id or model_id.startswith("arn:")
79
+
80
+
81
+ def _alias_values(values: Any) -> Any:
82
+ """Resolve `model_alias` / `model_id` and reject a queue alias passed as the model id.
83
+
84
+ Without `model_id`, it is looked up from `llmq.config` when a queue is given.
85
+ """
86
+ if not isinstance(values, dict):
87
+ return values
88
+ model_id = values.get("model_id", values.get("model"))
89
+ alias = values.get("model_alias")
90
+ if model_id is None and alias is not None:
91
+ config = getattr(values.get("llmq"), "config", None)
92
+ resolve = getattr(config, "resolve", None)
93
+ if not callable(resolve):
94
+ raise ValueError(
95
+ f"model_alias={alias!r} needs model_id='<Bedrock model id>' "
96
+ "(or llmq=<LLMQueue> so it can be looked up from the queue config)"
97
+ )
98
+ values["model_id"] = resolve(alias)[1].model_id
99
+ elif isinstance(model_id, str) and alias is None and not _looks_like_bedrock_id(model_id):
100
+ raise ValueError(
101
+ f"model={model_id!r} looks like a queue alias, not a Bedrock model id; pass "
102
+ f"model_alias={model_id!r} and model_id='<Bedrock model id>' "
103
+ "(e.g. 'openai.gpt-oss-120b-1:0'), or model_alias plus llmq=<LLMQueue>"
104
+ )
105
+ return values
106
+
107
+
108
+ def _service_tier(tier: Any) -> dict[str, Any]:
109
+ return tier if isinstance(tier, dict) else {"type": tier}
110
+
111
+
112
+ class QueuedChatBedrockConverse(ChatBedrockConverse):
113
+ """`ChatBedrockConverse` whose Converse calls go through the LLM queue.
114
+
115
+ `model_alias` is the queue alias (`gpt120b`, `gpt120b@apac`); `model_id` is the Bedrock id
116
+ (looked up from `llmq.config` when omitted) and drives the parent's provider-specific
117
+ request building. Streaming is always off (one full message).
118
+ """
119
+
120
+ llmq: Any = Field(default=None, exclude=True)
121
+ model_alias: str | None = None
122
+ fallback_policy: FallbackPolicy | None = None
123
+ lane: Lane | str | None = None
124
+ service_tier: str | None = None
125
+ queue_timeout_secs: float | None = None
126
+
127
+ @model_validator(mode="before")
128
+ @classmethod
129
+ def _queue_defaults(cls, values: Any) -> Any:
130
+ values = _queue_only_values(_alias_values(values), "client", "bedrock_client")
131
+ if isinstance(values, dict):
132
+ values["disable_streaming"] = True
133
+ return values
134
+
135
+ def _queue(self) -> QueueClient:
136
+ return resolve_queue(self.llmq)
137
+
138
+ @property
139
+ def _alias(self) -> str:
140
+ return self.model_alias or self.model_id
141
+
142
+ def _run_parent(
143
+ self,
144
+ messages: list[BaseMessage],
145
+ stop: list[str] | None,
146
+ kwargs: dict[str, Any],
147
+ handler: _Handler,
148
+ ) -> ChatResult:
149
+ token = _HANDLER.set(handler)
150
+ try:
151
+ return super()._generate(messages, stop, None, **kwargs)
152
+ finally:
153
+ _HANDLER.reset(token)
154
+
155
+ def _capture(
156
+ self, messages: list[BaseMessage], stop: list[str] | None, kwargs: dict[str, Any]
157
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
158
+ """(Converse request without `modelId`, kwargs for the parse pass) from the parent."""
159
+ call_kwargs = dict(kwargs)
160
+ tier = call_kwargs.pop("service_tier", None) # langchain-aws 0.2 has no such kwarg
161
+
162
+ def capture(request: dict[str, Any]) -> dict[str, Any]:
163
+ raise _Captured(request)
164
+
165
+ try:
166
+ self._run_parent(messages, stop, call_kwargs, capture)
167
+ except _Captured as captured:
168
+ request = dict(captured.request)
169
+ else: # pragma: no cover - the parent always calls converse
170
+ raise LLMQueueError("ChatBedrockConverse did not build a Converse request")
171
+ request.pop("modelId", None) # the queue alias picks the model and target
172
+ if tier is not None:
173
+ request["serviceTier"] = _service_tier(tier)
174
+ elif self.service_tier and "serviceTier" not in request:
175
+ request["serviceTier"] = _service_tier(self.service_tier)
176
+ return request, call_kwargs
177
+
178
+ def _parse(
179
+ self,
180
+ messages: list[BaseMessage],
181
+ stop: list[str] | None,
182
+ call_kwargs: dict[str, Any],
183
+ result: ConverseResultLike,
184
+ ) -> ChatResult:
185
+ """Parse like the parent (same call, canned reply), then add how the queue served it."""
186
+ chat = self._run_parent(messages, stop, call_kwargs, lambda _request: result.response)
187
+ meta = result.meta.model_dump()
188
+ for generation in chat.generations:
189
+ generation.message.response_metadata.update(meta)
190
+ return chat
191
+
192
+ def _generate(
193
+ self,
194
+ messages: list[BaseMessage],
195
+ stop: list[str] | None = None,
196
+ run_manager: CallbackManagerForLLMRun | None = None,
197
+ **kwargs: Any,
198
+ ) -> ChatResult:
199
+ """Sync path: `converse_sync` (direct under the shared limiter, see README)."""
200
+ request, call_kwargs = self._capture(messages, stop, kwargs)
201
+ result = self._queue().converse_sync(
202
+ self._alias,
203
+ request,
204
+ lane=self.lane,
205
+ fallback=self.fallback_policy,
206
+ timeout_secs=self.queue_timeout_secs,
207
+ )
208
+ return self._parse(messages, stop, call_kwargs, result)
209
+
210
+ async def _agenerate(
211
+ self,
212
+ messages: list[BaseMessage],
213
+ stop: list[str] | None = None,
214
+ run_manager: AsyncCallbackManagerForLLMRun | None = None,
215
+ **kwargs: Any,
216
+ ) -> ChatResult:
217
+ """Native async path: one queued Converse call (the parent only has a thread fallback)."""
218
+ request, call_kwargs = self._capture(messages, stop, kwargs)
219
+ result = await self._queue().converse(
220
+ self._alias,
221
+ request,
222
+ lane=self.lane,
223
+ fallback=self.fallback_policy,
224
+ timeout_secs=self.queue_timeout_secs,
225
+ )
226
+ return self._parse(messages, stop, call_kwargs, result)
227
+
228
+
229
+ class QueuedBedrockEmbeddings(BedrockEmbeddings):
230
+ """`BedrockEmbeddings` whose calls go through the queue's `embed` op (batched per provider).
231
+
232
+ `model_alias` is the queue alias (defaults to `model_id`; the queue builds the provider
233
+ body). Queries use `input_type="search_query"`, documents `"search_document"`; the provider
234
+ ignores it where the model has no such field. `normalize` is applied locally.
235
+ """
236
+
237
+ llmq: Any = Field(default=None, exclude=True)
238
+ model_alias: str | None = None
239
+ lane: Lane | str | None = None
240
+ queue_timeout_secs: float | None = None
241
+
242
+ @model_validator(mode="before")
243
+ @classmethod
244
+ def _queue_defaults(cls, values: Any) -> Any:
245
+ return _queue_only_values(values, "client")
246
+
247
+ def _queue(self) -> QueueClient:
248
+ return resolve_queue(self.llmq)
249
+
250
+ def _prepare(self, texts: list[str]) -> list[str]:
251
+ """Same newline handling as the parent, which says newlines hurt embedding quality."""
252
+ return [text.replace(os.linesep, " ") for text in texts]
253
+
254
+ def _finish(self, vectors: list[list[float]]) -> list[list[float]]:
255
+ return [self._normalize_vector(v) for v in vectors] if self.normalize else vectors
256
+
257
+ def _sync(self, texts: list[str], input_type: str) -> list[list[float]]:
258
+ result = self._queue().embed_sync(
259
+ self.model_alias or self.model_id,
260
+ self._prepare(texts),
261
+ lane=self.lane,
262
+ input_type=input_type,
263
+ timeout_secs=self.queue_timeout_secs,
264
+ )
265
+ return self._finish(result.vectors)
266
+
267
+ async def _async(self, texts: list[str], input_type: str) -> list[list[float]]:
268
+ result = await self._queue().embed(
269
+ self.model_alias or self.model_id,
270
+ self._prepare(texts),
271
+ lane=self.lane,
272
+ input_type=input_type,
273
+ timeout_secs=self.queue_timeout_secs,
274
+ )
275
+ return self._finish(result.vectors)
276
+
277
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
278
+ """Embed documents on the sync path (`embed_sync`)."""
279
+ return self._sync(texts, "search_document")
280
+
281
+ def embed_query(self, text: str) -> list[float]:
282
+ """Embed one query on the sync path (`embed_sync`)."""
283
+ return self._sync([text], "search_query")[0]
284
+
285
+ async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
286
+ """Embed documents through the queue as one call (the parent fans out per text)."""
287
+ return await self._async(texts, "search_document")
288
+
289
+ async def aembed_query(self, text: str) -> list[float]:
290
+ """Embed one query through the queue."""
291
+ return (await self._async([text], "search_query"))[0]
@@ -0,0 +1,103 @@
1
+ """Queued OpenAI-compatible adapters: `ChatOpenAI` / `OpenAIEmbeddings` over `QueuedHTTPTransport`.
2
+
3
+ Both SDK clients get an httpx client whose transport answers from the LLM queue (D88), so every
4
+ LangChain/SDK code path is queued and parsed unchanged. Streaming and SDK retries are forced off:
5
+ the queue's workers own retry and fallback. Chat replies get the queue's `CallMeta`
6
+ (served model/target, attempts, queue wait) in `response_metadata`, like the Bedrock adapter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import httpx
14
+ from langchain_openai import ChatOpenAI, OpenAIEmbeddings
15
+ from pydantic import Field, model_validator
16
+
17
+ from taskiq_llm_queue.config import Lane
18
+ from taskiq_llm_queue.envelope import FallbackPolicy
19
+ from taskiq_llm_queue.http_transport import META_KEY, QUEUED_BASE_URL, QueuedHTTPTransport
20
+
21
+ if TYPE_CHECKING:
22
+ from langchain_core.outputs import ChatResult
23
+
24
+ # Placeholder only: the SDK requires a key, but workers hold the real provider credentials.
25
+ _PLACEHOLDER_API_KEY = "llmq-queued"
26
+
27
+
28
+ def _queued_client_values(values: Any) -> Any:
29
+ """Point both SDK clients at a queued transport built from the raw constructor values."""
30
+ if not isinstance(values, dict):
31
+ return values
32
+ transport = QueuedHTTPTransport(
33
+ values.get("llmq"),
34
+ values.get("model_alias"),
35
+ lane=values.get("lane"),
36
+ fallback=values.get("fallback_policy"),
37
+ timeout_secs=values.get("queue_timeout_secs"),
38
+ )
39
+ values["http_async_client"] = httpx.AsyncClient(transport=transport, base_url=QUEUED_BASE_URL)
40
+ values["http_client"] = httpx.Client(transport=transport, base_url=QUEUED_BASE_URL)
41
+ values.pop("openai_api_base", None)
42
+ values["base_url"] = QUEUED_BASE_URL
43
+ values["openai_proxy"] = None # an OPENAI_PROXY env var would conflict with http_client
44
+ if values.get("api_key") is None and values.get("openai_api_key") is None:
45
+ values["api_key"] = _PLACEHOLDER_API_KEY
46
+ values["max_retries"] = 0
47
+ return values
48
+
49
+
50
+ class QueuedChatOpenAI(ChatOpenAI):
51
+ """`ChatOpenAI` whose HTTP calls are answered by the LLM queue's `chat` op.
52
+
53
+ `model_alias` is the queue alias; when unset, the request's `model` (this class's `model`)
54
+ is used as the alias. Streaming is always off, so `astream()` yields one full message.
55
+ """
56
+
57
+ llmq: Any = Field(default=None, exclude=True)
58
+ model_alias: str | None = None
59
+ fallback_policy: FallbackPolicy | None = None
60
+ lane: Lane | str | None = None
61
+ queue_timeout_secs: float | None = None
62
+
63
+ @model_validator(mode="before")
64
+ @classmethod
65
+ def _queue_defaults(cls, values: Any) -> Any:
66
+ values = _queued_client_values(values)
67
+ if isinstance(values, dict):
68
+ values["streaming"] = False
69
+ values["disable_streaming"] = True
70
+ return values
71
+
72
+ def _create_chat_result(
73
+ self, response: Any, generation_info: dict[str, Any] | None = None
74
+ ) -> ChatResult:
75
+ """Parse like the parent, then add the queue's `llmq_meta` to `response_metadata`."""
76
+ result = super()._create_chat_result(response, generation_info)
77
+ if isinstance(response, dict):
78
+ meta = response.get(META_KEY)
79
+ else:
80
+ meta = (getattr(response, "model_extra", None) or {}).get(META_KEY)
81
+ if isinstance(meta, dict):
82
+ for generation in result.generations:
83
+ generation.message.response_metadata.update(meta)
84
+ return result
85
+
86
+
87
+ class QueuedOpenAIEmbeddings(OpenAIEmbeddings):
88
+ """`OpenAIEmbeddings` whose HTTP calls are answered by the LLM queue's `embed` op.
89
+
90
+ `check_embedding_ctx_length` defaults to False because the queue embeds text, not tokens,
91
+ and non-OpenAI servers do not share OpenAI's tokenizer.
92
+ """
93
+
94
+ llmq: Any = Field(default=None, exclude=True)
95
+ model_alias: str | None = None
96
+ lane: Lane | str | None = None
97
+ queue_timeout_secs: float | None = None
98
+ check_embedding_ctx_length: bool = False
99
+
100
+ @model_validator(mode="before")
101
+ @classmethod
102
+ def _queue_defaults(cls, values: Any) -> Any:
103
+ return _queued_client_values(values)