mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
* fix(bedrock): send redacted_thinking back to Converse as reasoningContent.redactedContent Replaying an assistant turn that carried a redacted_thinking block to Bedrock Converse turned it into an empty unsigned reasoningText, which the assistant content builder then dropped as blank text. Bedrock returned 200 so callers never noticed. Map redacted_thinking to redactedContent in both the thinking_blocks and inline content paths of the sync and async builders * fix(bedrock): drop blank redacted_thinking data and keep non-str signatures signed on Converse replay * fix(bedrock): replay redacted_thinking to Converse only when the target is Claude Nova, Llama, DeepSeek and gpt-oss reject reasoningContent.redactedContent with a 400, so a Claude history replayed to them through a Router fallback or model switch keeps dropping the redacted block the way main does. Claude is resolved from the Bedrock base model of the target, and an application inference profile ARN counts as Claude when its deployment's base_model names a Claude model. Adds integration contracts for the replayed Converse request shape, stream and non-stream, with a tool call, and for non-Claude and ARN targets. * test(e2e): cover Bedrock Converse replaying redacted_thinking live through the proxy Turn 1 triggers redacted thinking on Claude Sonnet 4.6, turn 2 replays it, and a live edge between the proxy and Bedrock checks the replayed Converse body carries the same reasoningContent.redactedContent. Covers nonstream, stream and a tool call round trip * fix(bedrock): validate litellm_params before reading the deployment base_model litellm_params arrives as an untyped dict, so passing it straight into the typed helper grew reportUnknownArgumentType. Validating it into Mapping[str, object] first keeps the read typed without growing the type-check gate * fix(bedrock): wrap non-Converse redacted_thinking data and let a named model id win over base_model * test(bedrock): cover redacted_thinking minted outside Converse, model_id and the sync Router base_model path * fix(bedrock): match bare claude- base_model names without reading the untyped cost map * fix(bedrock): keep redacted_thinking data as Claude's own string on every route Converse hands redactedContent back base64-wrapped, and that wrapper used to leak out as data, so a Converse turn replayed to Invoke or the Anthropic API was rejected. Converse replies now decode it, Converse requests wrap it exactly once, and the Anthropic-shaped builders unwrap histories saved with the old wrapping. Responses API encrypted reasoning is no longer sent to Claude on Converse, which rejected it with a 400
89 lines
3.8 KiB
Python
89 lines
3.8 KiB
Python
"""SigV4 re-signing for Bedrock traffic routed through the provider edge.
|
|
|
|
Bedrock is the one provider the edge could never mount. SigV4 signs the Host
|
|
header, so rewriting ``api_base`` to point at the edge invalidates the proxy's
|
|
signature and Bedrock rejects the call before it reaches a model. The edge
|
|
therefore has to drop the proxy's signature and mint its own over the upstream
|
|
URL it is actually about to call.
|
|
|
|
The identity it signs with is the run pod's own, from the EKS Pod Identity
|
|
association on ServiceAccount ``buildkite-e2e-run``. That role carries Bedrock
|
|
invoke and converse on an allowlist of the Anthropic models the suite registers
|
|
and nothing else, so a re-signed call can reach exactly the models the suite
|
|
already uses. The proxy's own Bedrock credentials are not involved in a routed
|
|
deployment, which is why ``aws_role_name`` deployments stay off the edge: their
|
|
whole point is to prove the product's assume-role chain.
|
|
|
|
Signature headers are excluded from the cache key by the caller, and they have
|
|
to be: ``x-amz-date`` is a timestamp, so keying on it would make every Bedrock
|
|
request a permanent miss.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import queue
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass, field
|
|
from typing import Final
|
|
|
|
from botocore.auth import SigV4Auth
|
|
from botocore.awsrequest import AWSRequest
|
|
from botocore.credentials import Credentials
|
|
from botocore.session import Session
|
|
from provider_cache import SIGNATURE_HEADERS
|
|
|
|
BEDROCK_SERVICE: Final = "bedrock"
|
|
|
|
|
|
class MissingAwsCredentials(RuntimeError):
|
|
"""No AWS identity is resolvable, so the edge cannot sign for Bedrock."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class BedrockSigner:
|
|
region: str
|
|
credentials: Callable[[], Credentials]
|
|
|
|
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None) -> dict[str, str]:
|
|
unsigned: Final = {
|
|
name: value for name, value in headers.items() if name.lower() not in SIGNATURE_HEADERS
|
|
}
|
|
request: Final = AWSRequest(method=method, url=url, headers=unsigned, data=body or b"")
|
|
SigV4Auth(self.credentials(), BEDROCK_SERVICE, self.region).add_auth(request)
|
|
return dict(request.headers)
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def pod_credentials() -> Credentials:
|
|
"""The run pod's own identity, resolved once per process through botocore's
|
|
ordinary chain, which reaches Pod Identity at the ``container-role`` link."""
|
|
resolved: Final = Session().get_credentials()
|
|
if resolved is None: # pyright: ignore[reportUnnecessaryComparison] # stubs miss the empty-chain None
|
|
raise MissingAwsCredentials(
|
|
"the provider edge is mounted for Bedrock but no AWS credentials resolve; "
|
|
"the run pod gets them from the Pod Identity association on buildkite-e2e-run"
|
|
)
|
|
return resolved
|
|
|
|
|
|
def bedrock_signer(region: str, credentials: Callable[[], Credentials] = pod_credentials) -> BedrockSigner:
|
|
"""Credentials are resolved on the first signed request, not here, so a run
|
|
that mounts Bedrock but never calls it needs no AWS identity at all."""
|
|
return BedrockSigner(region, credentials)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ConverseBodyCapture:
|
|
"""The raw Converse bodies the proxy sent upstream, seen by a live edge
|
|
between the proxy and Bedrock. Pass ``observe`` as the edge's
|
|
``observe_request`` and ``drain`` once the calls under test are done."""
|
|
|
|
_bodies: queue.SimpleQueue[bytes] = field(default_factory=queue.SimpleQueue)
|
|
|
|
def observe(self, url: str, headers: Mapping[str, str], body: bytes | None) -> None:
|
|
if body is not None and "/converse" in url:
|
|
self._bodies.put(body)
|
|
|
|
def drain(self) -> tuple[bytes, ...]:
|
|
return tuple(self._bodies.get_nowait() for _ in range(self._bodies.qsize()))
|