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,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