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,66 @@
|
|
|
1
|
+
"""Optional Prometheus `MetricsSink` for local runs and the Docker stack (`[metrics]` extra).
|
|
2
|
+
|
|
3
|
+
`prometheus_client` is imported when the sink is built, never at package import, and a missing
|
|
4
|
+
install raises an `ImportError` naming the extra. Label names of a metric are fixed by its first
|
|
5
|
+
use; later calls fill missing labels with "" and drop unknown ones.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from taskiq_llm_queue._logging import get_logger
|
|
16
|
+
from taskiq_llm_queue.metrics_names import LABELS
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
INSTALL_HINT = "PrometheusMetrics needs prometheus-client: pip install 'taskiq-llm-queue[metrics]'"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PrometheusMetrics:
|
|
23
|
+
"""`MetricsSink` backed by prometheus_client Counter / Histogram / Gauge objects.
|
|
24
|
+
|
|
25
|
+
Pass a `registry` for isolation in tests; the default is prometheus_client's global one.
|
|
26
|
+
`serve(port)` starts the client's HTTP exporter for `/metrics`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, registry: Any = None) -> None:
|
|
30
|
+
"""Import prometheus_client now; raises `ImportError` with the extra to install."""
|
|
31
|
+
try:
|
|
32
|
+
self._prom: Any = importlib.import_module("prometheus_client")
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise ImportError(INSTALL_HINT) from exc
|
|
35
|
+
self._registry = registry if registry is not None else self._prom.REGISTRY
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
self._metrics: dict[str, tuple[Any, tuple[str, ...]]] = {}
|
|
38
|
+
|
|
39
|
+
def incr(self, name: str, value: float = 1.0, labels: Mapping[str, str] | None = None) -> None:
|
|
40
|
+
"""Increase a Counter."""
|
|
41
|
+
self._child("Counter", name, labels).inc(value)
|
|
42
|
+
|
|
43
|
+
def observe(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
44
|
+
"""Observe into a Histogram (default buckets)."""
|
|
45
|
+
self._child("Histogram", name, labels).observe(value)
|
|
46
|
+
|
|
47
|
+
def gauge(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
48
|
+
"""Set a Gauge."""
|
|
49
|
+
self._child("Gauge", name, labels).set(value)
|
|
50
|
+
|
|
51
|
+
def serve(self, port: int, addr: str = "0.0.0.0") -> None:
|
|
52
|
+
"""Expose `/metrics` on `addr:port` from a daemon thread."""
|
|
53
|
+
self._prom.start_http_server(port, addr=addr, registry=self._registry)
|
|
54
|
+
|
|
55
|
+
def _child(self, kind: str, name: str, labels: Mapping[str, str] | None) -> Any:
|
|
56
|
+
given = {k: str(v) for k, v in (labels or {}).items() if k in LABELS}
|
|
57
|
+
with self._lock:
|
|
58
|
+
found = self._metrics.get(name)
|
|
59
|
+
if found is None:
|
|
60
|
+
names = tuple(sorted(given))
|
|
61
|
+
metric = getattr(self._prom, kind)(
|
|
62
|
+
name, name, labelnames=names, registry=self._registry
|
|
63
|
+
)
|
|
64
|
+
found = self._metrics[name] = (metric, names)
|
|
65
|
+
metric, names = found
|
|
66
|
+
return metric.labels(**{n: given.get(n, "") for n in names}) if names else metric
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Metric names and label sets, fixed here so dashboards and alarms don't drift from code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
QUEUE_WAIT_SECONDS = "llmq_queue_wait_seconds"
|
|
6
|
+
PROVIDER_SECONDS = "llmq_provider_seconds"
|
|
7
|
+
CALLS_TOTAL = "llmq_calls_total"
|
|
8
|
+
THROTTLES_TOTAL = "llmq_throttles_total"
|
|
9
|
+
FALLBACK_TOTAL = "llmq_fallback_total"
|
|
10
|
+
LIMIT = "llmq_limit"
|
|
11
|
+
INFLIGHT = "llmq_inflight"
|
|
12
|
+
BREAKER_OPEN = "llmq_breaker_open"
|
|
13
|
+
SYNC_FALLBACK_TOTAL = "llmq_sync_fallback_total"
|
|
14
|
+
RELEASED_UNKNOWN_TOTAL = "llmq_released_unknown_total"
|
|
15
|
+
CONFIG_INVALID_TOTAL = "llmq_config_invalid_total"
|
|
16
|
+
FAILOVER_ACTIVE = "llmq_failover_active"
|
|
17
|
+
|
|
18
|
+
# Allowed labels; raw user/session ids are never metric labels (cardinality, privacy).
|
|
19
|
+
LABELS = ("model", "target", "lane", "queue", "path", "kind", "step")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Interfaces between the layers: providers, limiter, claims, results and receive gates.
|
|
2
|
+
|
|
3
|
+
Each workstream implements one of these and codes against the others, so pieces can be built
|
|
4
|
+
and tested in parallel with in-memory fakes before the real implementations land.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from taskiq_llm_queue.config import Lane, Target
|
|
16
|
+
from taskiq_llm_queue.envelope import LLMReply, Op
|
|
17
|
+
from taskiq_llm_queue.errors import ErrorInfo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClaimState(StrEnum):
|
|
21
|
+
"""Lifecycle of one task's claim record in Redis."""
|
|
22
|
+
|
|
23
|
+
QUEUED = "queued"
|
|
24
|
+
RUNNING = "running"
|
|
25
|
+
DONE = "done"
|
|
26
|
+
CANCELLED = "cancelled"
|
|
27
|
+
EXPIRED = "expired"
|
|
28
|
+
RELEASED = "released"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClaimDecision(StrEnum):
|
|
32
|
+
"""What a worker does with a received message after trying to claim it."""
|
|
33
|
+
|
|
34
|
+
RUN = "run"
|
|
35
|
+
DEFER = "defer" # another pod is running it: extend visibility, never delete
|
|
36
|
+
ACK_DONE = "ack_done"
|
|
37
|
+
ACK_CANCELLED = "ack_cancelled"
|
|
38
|
+
ACK_EXPIRED = "ack_expired"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Lease:
|
|
43
|
+
"""One in-flight slot held for one provider attempt; released in `finally`."""
|
|
44
|
+
|
|
45
|
+
model: str
|
|
46
|
+
target: str
|
|
47
|
+
token: str
|
|
48
|
+
expires_at: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class Provider(Protocol):
|
|
53
|
+
"""An LLM backend: performs one attempt and classifies its failures."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
|
|
57
|
+
async def call(self, op: Op, params: Mapping[str, Any], target: Target) -> dict[str, Any]:
|
|
58
|
+
"""Run one attempt and return the provider-native response body."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def classify(self, exc: BaseException) -> ErrorInfo:
|
|
62
|
+
"""Map a provider exception to throttle / retryable / fatal."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@runtime_checkable
|
|
67
|
+
class ReceiveGate(Protocol):
|
|
68
|
+
"""Lets the broker stop receiving for a queue, e.g. while its model is throttled."""
|
|
69
|
+
|
|
70
|
+
def is_open(self) -> bool:
|
|
71
|
+
"""True when the broker may receive now."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
async def wait_open(self) -> None:
|
|
75
|
+
"""Return once receiving is allowed again."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@runtime_checkable
|
|
80
|
+
class Limiter(Protocol):
|
|
81
|
+
"""Cluster-wide adaptive concurrency limit per model target, learned from throttles."""
|
|
82
|
+
|
|
83
|
+
async def acquire(
|
|
84
|
+
self,
|
|
85
|
+
model: str,
|
|
86
|
+
target: str,
|
|
87
|
+
lane: Lane,
|
|
88
|
+
timeout_secs: float,
|
|
89
|
+
*,
|
|
90
|
+
should_abort: Callable[[], Awaitable[bool]] | None = None,
|
|
91
|
+
) -> Lease | None:
|
|
92
|
+
"""Take a slot for one attempt, or None if none frees up within the timeout.
|
|
93
|
+
|
|
94
|
+
`should_abort` (e.g. the cancel flag, DS14) is checked while waiting; True gives up.
|
|
95
|
+
"""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
async def release(self, lease: Lease, *, throttled: bool) -> None:
|
|
99
|
+
"""Return the slot and feed the outcome into the limit (grow or shrink)."""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
def gate(self, model: str, target: str) -> ReceiveGate:
|
|
103
|
+
"""Gate that closes while this model target is paused after a throttle."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@runtime_checkable
|
|
108
|
+
class ClaimStore(Protocol):
|
|
109
|
+
"""Exactly-once execution on top of at-least-once delivery."""
|
|
110
|
+
|
|
111
|
+
async def claim(self, request_id: str, *, deadline_at: float, worker: str) -> ClaimDecision:
|
|
112
|
+
"""Atomically decide whether this worker runs the task (Lua CAS)."""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
async def heartbeat(self, request_ids: Sequence[str], *, worker: str) -> None:
|
|
116
|
+
"""Refresh heartbeats and TTLs for running tasks."""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
async def complete(
|
|
120
|
+
self, request_id: str, state: ClaimState, *, worker: str | None = None
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Mark a task done, cancelled or expired after its result is stored (owner `worker`)."""
|
|
123
|
+
...
|
|
124
|
+
|
|
125
|
+
async def release(self, request_id: str, *, worker: str | None = None) -> None:
|
|
126
|
+
"""Hand a running task back so another pod can start it at once (owner `worker`)."""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@runtime_checkable
|
|
131
|
+
class ResultStore(Protocol):
|
|
132
|
+
"""Stores task results and hands them to waiting callers in batches."""
|
|
133
|
+
|
|
134
|
+
async def put(self, reply: LLMReply, *, ttl_secs: int) -> None:
|
|
135
|
+
"""Store one result (JSON) with a TTL."""
|
|
136
|
+
...
|
|
137
|
+
|
|
138
|
+
async def take_many(self, task_ids: Sequence[str]) -> dict[str, LLMReply]:
|
|
139
|
+
"""Fetch and delete whichever of these results are ready."""
|
|
140
|
+
...
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""LLM providers: Amazon Bedrock and OpenAI-compatible servers behind the `Provider` protocol.
|
|
2
|
+
|
|
3
|
+
Workers call `ProviderRegistry.for_model(alias).call(op, params, target)` and classify
|
|
4
|
+
failures with `.classify(exc)`. Importing this package never imports aiobotocore or openai;
|
|
5
|
+
each SDK is imported on a provider's first call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from taskiq_llm_queue.providers.bedrock import BedrockProvider, decode_invoke_body
|
|
11
|
+
from taskiq_llm_queue.providers.classify import (
|
|
12
|
+
classify_bedrock,
|
|
13
|
+
classify_openai,
|
|
14
|
+
error_family,
|
|
15
|
+
parse_retry_after,
|
|
16
|
+
)
|
|
17
|
+
from taskiq_llm_queue.providers.embeddings import (
|
|
18
|
+
COHERE_MAX_BATCH,
|
|
19
|
+
OPENAI_DEFAULT_CHUNK_SIZE,
|
|
20
|
+
TITAN_MAX_BATCH,
|
|
21
|
+
batches,
|
|
22
|
+
cohere_body,
|
|
23
|
+
embedding_body,
|
|
24
|
+
max_batch_size,
|
|
25
|
+
parse_embeddings,
|
|
26
|
+
titan_body,
|
|
27
|
+
)
|
|
28
|
+
from taskiq_llm_queue.providers.hosts import check_url, denied_pattern
|
|
29
|
+
from taskiq_llm_queue.providers.openai_compat import OpenAICompatProvider
|
|
30
|
+
from taskiq_llm_queue.providers.registry import (
|
|
31
|
+
BUILTIN_PROVIDERS,
|
|
32
|
+
ENTRY_POINT_GROUP,
|
|
33
|
+
ProviderFactory,
|
|
34
|
+
ProviderRegistry,
|
|
35
|
+
load_object,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BUILTIN_PROVIDERS",
|
|
40
|
+
"COHERE_MAX_BATCH",
|
|
41
|
+
"ENTRY_POINT_GROUP",
|
|
42
|
+
"OPENAI_DEFAULT_CHUNK_SIZE",
|
|
43
|
+
"TITAN_MAX_BATCH",
|
|
44
|
+
"BedrockProvider",
|
|
45
|
+
"OpenAICompatProvider",
|
|
46
|
+
"ProviderFactory",
|
|
47
|
+
"ProviderRegistry",
|
|
48
|
+
"batches",
|
|
49
|
+
"check_url",
|
|
50
|
+
"classify_bedrock",
|
|
51
|
+
"classify_openai",
|
|
52
|
+
"cohere_body",
|
|
53
|
+
"decode_invoke_body",
|
|
54
|
+
"denied_pattern",
|
|
55
|
+
"embedding_body",
|
|
56
|
+
"error_family",
|
|
57
|
+
"load_object",
|
|
58
|
+
"max_batch_size",
|
|
59
|
+
"parse_embeddings",
|
|
60
|
+
"parse_retry_after",
|
|
61
|
+
"titan_body",
|
|
62
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Amazon Bedrock runtime provider: Converse and InvokeModel through aiobotocore.
|
|
2
|
+
|
|
3
|
+
Payloads are provider-native and pass through unchanged, so responses are exactly what
|
|
4
|
+
`ChatBedrockConverse` / `BedrockEmbeddings` parse. SDK retries are off (one attempt per call)
|
|
5
|
+
so throttles reach the adaptive limiter instead of being hidden by botocore.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
from contextlib import AsyncExitStack
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import orjson
|
|
17
|
+
|
|
18
|
+
from taskiq_llm_queue.errors import ConfigError, ErrorInfo
|
|
19
|
+
from taskiq_llm_queue.providers.classify import classify_bedrock
|
|
20
|
+
from taskiq_llm_queue.providers.hosts import check_url
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from aiobotocore.session import AioSession
|
|
24
|
+
from types_aiobotocore_bedrock_runtime import BedrockRuntimeClient
|
|
25
|
+
|
|
26
|
+
from taskiq_llm_queue.config import QueueConfig, Target
|
|
27
|
+
from taskiq_llm_queue.envelope import Op
|
|
28
|
+
|
|
29
|
+
# Unverified starting values: Bedrock replies can run minutes, and the pool must not be the
|
|
30
|
+
# bottleneck below the limiter (botocore's default pool is 10 connections per client).
|
|
31
|
+
DEFAULT_READ_TIMEOUT_SECS = 300.0
|
|
32
|
+
DEFAULT_CONNECT_TIMEOUT_SECS = 10.0
|
|
33
|
+
DEFAULT_MAX_POOL_CONNECTIONS = 100
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BedrockProvider:
|
|
37
|
+
"""`Provider` for the Bedrock runtime: ops `converse` and `invoke_model`.
|
|
38
|
+
|
|
39
|
+
One aiobotocore client per (region, endpoint), created on first use and closed by
|
|
40
|
+
`aclose()`. The model id is `Target.profile_id` when set, else `params["modelId"]`.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name = "bedrock"
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
session: AioSession | None = None,
|
|
49
|
+
region: str | None = None,
|
|
50
|
+
endpoint_url: str | None = None,
|
|
51
|
+
denied_hosts: Iterable[str] = (),
|
|
52
|
+
read_timeout_secs: float = DEFAULT_READ_TIMEOUT_SECS,
|
|
53
|
+
connect_timeout_secs: float = DEFAULT_CONNECT_TIMEOUT_SECS,
|
|
54
|
+
max_pool_connections: int = DEFAULT_MAX_POOL_CONNECTIONS,
|
|
55
|
+
client_kwargs: Mapping[str, Any] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Keep settings only; no SDK import and no client until the first call.
|
|
58
|
+
|
|
59
|
+
`region=None` (and no `Target.region`) lets the AWS chain pick the region.
|
|
60
|
+
`client_kwargs` go to `create_client` (e.g. explicit credentials for a fake endpoint).
|
|
61
|
+
"""
|
|
62
|
+
self._denied_hosts = tuple(denied_hosts)
|
|
63
|
+
check_url(endpoint_url, self._denied_hosts)
|
|
64
|
+
self._session = session
|
|
65
|
+
self._region = region
|
|
66
|
+
self._endpoint_url = endpoint_url
|
|
67
|
+
self._config_kwargs: dict[str, Any] = {
|
|
68
|
+
"read_timeout": read_timeout_secs,
|
|
69
|
+
"connect_timeout": connect_timeout_secs,
|
|
70
|
+
"max_pool_connections": max_pool_connections,
|
|
71
|
+
# `max_attempts: 1` would mean one *retry* (botocore adds 1); total is exact.
|
|
72
|
+
"retries": {"total_max_attempts": 1, "mode": "standard"},
|
|
73
|
+
}
|
|
74
|
+
self._client_kwargs = dict(client_kwargs or {})
|
|
75
|
+
self._clients: dict[tuple[str | None, str | None], BedrockRuntimeClient] = {}
|
|
76
|
+
self._stack = AsyncExitStack()
|
|
77
|
+
self._lock = asyncio.Lock()
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_config(
|
|
81
|
+
cls, config: QueueConfig, *, session: AioSession | None = None, **kwargs: Any
|
|
82
|
+
) -> BedrockProvider:
|
|
83
|
+
"""Build from `QueueConfig` (region, endpoint, deny list); checks every Bedrock target URL.
|
|
84
|
+
|
|
85
|
+
`session` is the pod's shared `AioSession` (`LLMQueue.start` passes it); extra keyword
|
|
86
|
+
arguments go to the constructor (e.g. `client_kwargs`) and win over the config's
|
|
87
|
+
`provider_read_timeout_secs` / `provider_connect_timeout_secs`.
|
|
88
|
+
"""
|
|
89
|
+
for model in config.models.values():
|
|
90
|
+
if model.provider == "bedrock":
|
|
91
|
+
for target in model.targets.values():
|
|
92
|
+
check_url(target.base_url, config.denied_hosts)
|
|
93
|
+
return cls(
|
|
94
|
+
region=config.aws_region,
|
|
95
|
+
endpoint_url=config.bedrock_endpoint_url,
|
|
96
|
+
denied_hosts=config.denied_hosts,
|
|
97
|
+
session=session,
|
|
98
|
+
**{
|
|
99
|
+
"read_timeout_secs": config.provider_read_timeout_secs,
|
|
100
|
+
"connect_timeout_secs": config.provider_connect_timeout_secs,
|
|
101
|
+
**kwargs,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def call(self, op: Op, params: Mapping[str, Any], target: Target) -> dict[str, Any]:
|
|
106
|
+
"""Run one Converse or InvokeModel attempt and return a JSON-safe response dict.
|
|
107
|
+
|
|
108
|
+
`converse` returns the Converse body without `ResponseMetadata`; `invoke_model` returns
|
|
109
|
+
`{"body_b64", "contentType"}` (see `decode_invoke_body`).
|
|
110
|
+
"""
|
|
111
|
+
request = dict(params)
|
|
112
|
+
model_id = target.profile_id or request.get("modelId")
|
|
113
|
+
if not model_id:
|
|
114
|
+
raise ValueError("Bedrock call needs Target.profile_id or params['modelId']")
|
|
115
|
+
request["modelId"] = model_id
|
|
116
|
+
if op == "converse":
|
|
117
|
+
client = await self._client(target)
|
|
118
|
+
response: dict[str, Any] = dict(await client.converse(**request))
|
|
119
|
+
response.pop("ResponseMetadata", None)
|
|
120
|
+
return response
|
|
121
|
+
if op == "invoke_model":
|
|
122
|
+
body = request.get("body")
|
|
123
|
+
if isinstance(body, Mapping | list):
|
|
124
|
+
request["body"] = orjson.dumps(body)
|
|
125
|
+
request.setdefault("contentType", "application/json")
|
|
126
|
+
request.setdefault("accept", "application/json")
|
|
127
|
+
client = await self._client(target)
|
|
128
|
+
raw = await client.invoke_model(**request)
|
|
129
|
+
async with raw["body"] as stream:
|
|
130
|
+
data = await stream.read()
|
|
131
|
+
return {
|
|
132
|
+
"body_b64": base64.b64encode(data).decode("ascii"),
|
|
133
|
+
"contentType": raw.get("contentType", ""),
|
|
134
|
+
}
|
|
135
|
+
raise ValueError(f"Bedrock provider does not support op {op!r}")
|
|
136
|
+
|
|
137
|
+
def classify(self, exc: BaseException) -> ErrorInfo:
|
|
138
|
+
"""Throttle / retryable / fatal by Bedrock error family (see `classify_bedrock`)."""
|
|
139
|
+
return classify_bedrock(exc)
|
|
140
|
+
|
|
141
|
+
async def aclose(self) -> None:
|
|
142
|
+
"""Close every client this provider created; safe to call more than once."""
|
|
143
|
+
async with self._lock:
|
|
144
|
+
self._clients.clear()
|
|
145
|
+
stack, self._stack = self._stack, AsyncExitStack()
|
|
146
|
+
await stack.aclose()
|
|
147
|
+
|
|
148
|
+
async def _client(self, target: Target) -> BedrockRuntimeClient:
|
|
149
|
+
"""Return the cached client for this target's region and endpoint, creating it once."""
|
|
150
|
+
endpoint = target.base_url or self._endpoint_url
|
|
151
|
+
region = target.region or self._region
|
|
152
|
+
key = (region, endpoint)
|
|
153
|
+
if (client := self._clients.get(key)) is not None:
|
|
154
|
+
return client
|
|
155
|
+
check_url(endpoint, self._denied_hosts)
|
|
156
|
+
async with self._lock:
|
|
157
|
+
if (client := self._clients.get(key)) is not None:
|
|
158
|
+
return client
|
|
159
|
+
try:
|
|
160
|
+
from aiobotocore.config import AioConfig
|
|
161
|
+
from aiobotocore.session import get_session
|
|
162
|
+
except ImportError as exc: # pragma: no cover - aiobotocore is a core dependency
|
|
163
|
+
raise ConfigError(
|
|
164
|
+
"BedrockProvider needs aiobotocore: pip install 'taskiq-llm-queue'"
|
|
165
|
+
) from exc
|
|
166
|
+
if self._session is None:
|
|
167
|
+
self._session = get_session()
|
|
168
|
+
context = self._session.create_client(
|
|
169
|
+
"bedrock-runtime",
|
|
170
|
+
region_name=region,
|
|
171
|
+
endpoint_url=endpoint,
|
|
172
|
+
config=AioConfig(**self._config_kwargs),
|
|
173
|
+
**self._client_kwargs,
|
|
174
|
+
)
|
|
175
|
+
client = await self._stack.enter_async_context(context)
|
|
176
|
+
self._clients[key] = client
|
|
177
|
+
return client
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def decode_invoke_body(response: Mapping[str, Any]) -> Any:
|
|
181
|
+
"""Decode an `invoke_model` result: parsed JSON for JSON content types, else raw bytes.
|
|
182
|
+
|
|
183
|
+
Used by embedding callers to turn `{"body_b64", "contentType"}` back into the model body.
|
|
184
|
+
"""
|
|
185
|
+
data = base64.b64decode(response["body_b64"])
|
|
186
|
+
if "json" in str(response.get("contentType", "")).lower():
|
|
187
|
+
return orjson.loads(data)
|
|
188
|
+
return data
|