feat(bedrock): apply bedrock_request_metadata_fields on the /bedrock pass-through routes (#47040)

* feat(bedrock): apply bedrock_request_metadata_fields on the /bedrock pass-through routes

* fix(bedrock): move Invoke body requestMetadata into the signed header and match Bedrock's ASCII whitespace rule

* fix(bedrock): keep Invoke body requestMetadata in the body and answer bad metadata as AWS does

Invoke requests whose body already carries requestMetadata keep the resolved pairs in the body
instead of the 8,500-character signed header. Malformed caller metadata now answers 400 with the
ValidationException error type the AWS SDK expects, and drop_params drops bad pairs on both the
pass-through routes and /v1/chat/completions. The caller-pair dedupe is linear, and the chat
validator treats \s as ASCII whitespace, as Bedrock does

* fix(bedrock): match Bedrock's 400s and header limit on pass-through request metadata

Invoke metadata too long for the 8,500-character header now goes in the body,
Invoke requestMetadata null answers 400 like Bedrock, Converse type errors carry
SerializationException, violation messages name the failing key, and the reserved
user_api_key_ prefix is matched case-insensitively after trimming whitespace

* fix(bedrock): trim the Invoke metadata header to Bedrock's limit and drop bad requestMetadata under drop_params without the setting

* fix(bedrock): leave /bedrock pass-through bodies as sent when request metadata forwarding is off

Global drop_params no longer rewrites requestMetadata on the pass-through routes unless
bedrock_request_metadata_fields is set. Chat keeps dropping bad pairs under drop_params.
Adds a tests/integration wire test for the four pass-through actions and removes an unused
parameter from the Invoke header helper.

* test(bedrock): drive the pass-through metadata wire test with boto3's bearer key env var

* fix(bedrock): trim the pass-through invoke metadata header and stop Nova invoke from checking metadata it drops

* test(bedrock): cover pass-through metadata header trimming, malformed caller metadata, and chat drop_params on the wire

* fix(bedrock): match requestMetadata keys and values against the whole string so a trailing newline cannot pass the 256-character limit
This commit is contained in:
Mateo Wang 2026-10-17 14:01:50 +00:00 • committed by GitHub
parent caad4d6204
commit 82ca10cc2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1043 additions and 124 deletions

View file

@ -66,7 +66,9 @@ from litellm.llms.bedrock.common_utils import (
)
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
bedrock_request_metadata_is_owned,
bedrock_request_metadata_is_rewritten,
bedrock_request_metadata_violation,
caller_request_metadata_violations_are_dropped,
merge_bedrock_invoke_headers,
resolve_bedrock_request_metadata,
)
@ -354,77 +356,15 @@ class AmazonConverseConfig(BaseConfig):
and v is not None
}
def _validate_request_metadata(self, metadata: dict) -> None:
"""
Validate requestMetadata according to AWS Bedrock Converse API constraints.
Constraints:
- Maximum of 16 items
- Keys: 1-256 characters, pattern [a-zA-Z0-9\\s:_@$#=/+,-.]{1,256}
- Values: 0-256 characters, pattern [a-zA-Z0-9\\s:_@$#=/+,-.]{0,256}
"""
import re
if not isinstance(metadata, dict):
def _validate_request_metadata(self, metadata: object) -> None:
violation: Final = bedrock_request_metadata_violation(metadata)
if violation is not None:
raise litellm.exceptions.BadRequestError(
message="requestMetadata must be a dictionary",
message=violation.message,
model="bedrock",
llm_provider="bedrock",
)
if len(metadata) > 16:
raise litellm.exceptions.BadRequestError(
message="requestMetadata can contain a maximum of 16 items",
model="bedrock",
llm_provider="bedrock",
)
key_pattern: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{1,256}$")
value_pattern: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{0,256}$")
for key, value in metadata.items():
if not isinstance(key, str):
raise litellm.exceptions.BadRequestError(
message="requestMetadata keys must be strings",
model="bedrock",
llm_provider="bedrock",
)
if not isinstance(value, str):
raise litellm.exceptions.BadRequestError(
message="requestMetadata values must be strings",
model="bedrock",
llm_provider="bedrock",
)
if len(key) == 0 or len(key) > 256:
raise litellm.exceptions.BadRequestError(
message="requestMetadata key length must be 1-256 characters",
model="bedrock",
llm_provider="bedrock",
)
if len(value) > 256:
raise litellm.exceptions.BadRequestError(
message="requestMetadata value length must be 0-256 characters",
model="bedrock",
llm_provider="bedrock",
)
if not key_pattern.match(key):
raise litellm.exceptions.BadRequestError(
message=f"requestMetadata key '{key}' contains invalid characters. Allowed: [a-zA-Z0-9\\s:_@$#=/+,.-]",
model="bedrock",
llm_provider="bedrock",
)
if not value_pattern.match(value):
raise litellm.exceptions.BadRequestError(
message=f"requestMetadata value '{value}' contains invalid characters. Allowed: [a-zA-Z0-9\\s:_@$#=/+,.-]",
model="bedrock",
llm_provider="bedrock",
)
@staticmethod
def _is_openai_gpt_reasoning_model(model: str) -> bool:
return re.search(r"openai\.gpt-\d", model) is not None
@ -1244,7 +1184,7 @@ class AmazonConverseConfig(BaseConfig):
elif param == "context_management" and isinstance(value, (dict, list)):
self._map_context_management_param(value, optional_params)
if param == "requestMetadata":
self._map_request_metadata_param(value, optional_params)
self._map_request_metadata_param(value, optional_params, drop_params)
if param == "service_tier" and isinstance(value, str):
self._map_service_tier_param(value, optional_params)
@ -1277,9 +1217,10 @@ class AmazonConverseConfig(BaseConfig):
return optional_params
def _map_request_metadata_param(self, value: object, optional_params: dict) -> None:
def _map_request_metadata_param(self, value: object, optional_params: dict, drop_params: bool) -> None:
if value is not None and isinstance(value, dict):
self._validate_request_metadata(value)
if not caller_request_metadata_violations_are_dropped(drop_params):
self._validate_request_metadata(value)
optional_params["requestMetadata"] = value
def _map_context_management_param(self, value: dict | list, optional_params: dict) -> None:
@ -1737,7 +1678,7 @@ class AmazonConverseConfig(BaseConfig):
# Extract requestMetadata before processing other parameters
request_metadata: Final = inference_params.pop("requestMetadata", None)
if request_metadata is not None:
if request_metadata is not None and not caller_request_metadata_violations_are_dropped(drop_params):
self._validate_request_metadata(request_metadata)
output_config: OutputConfigBlock | None = inference_params.pop("outputConfig", None)
@ -2164,7 +2105,7 @@ class AmazonConverseConfig(BaseConfig):
request_metadata: Final = resolve_bedrock_request_metadata(
litellm_params=litellm_params, caller_metadata=_data.get("requestMetadata")
)
if bedrock_request_metadata_is_owned():
if bedrock_request_metadata_is_rewritten(litellm_params.get("drop_params") is True):
_data.pop("requestMetadata", None)
if request_metadata is not None:
_data["requestMetadata"] = request_metadata
@ -2244,7 +2185,7 @@ class AmazonConverseConfig(BaseConfig):
request_metadata: Final = resolve_bedrock_request_metadata(
litellm_params=litellm_params, caller_metadata=_data.get("requestMetadata")
)
if bedrock_request_metadata_is_owned():
if bedrock_request_metadata_is_rewritten(litellm_params.get("drop_params") is True):
_data.pop("requestMetadata", None)
if request_metadata is not None:
_data["requestMetadata"] = request_metadata

View file

@ -95,7 +95,12 @@ class AmazonInvokeNovaConfig(AmazonInvokeConfig, AmazonConverseConfig):
model_info: Mapping[str, object] | None = None,
) -> dict:
return AmazonConverseConfig.map_openai_params(
self, non_default_params, optional_params, model, drop_params, model_info
self,
{key: value for key, value in non_default_params.items() if key != "requestMetadata"},
optional_params,
model,
drop_params,
model_info,
)
def transform_request(
@ -110,7 +115,9 @@ class AmazonInvokeNovaConfig(AmazonInvokeConfig, AmazonConverseConfig):
self,
model=model,
messages=messages,
optional_params=_without_tool_config_injection_points(optional_params),
optional_params=_without_tool_config_injection_points(
{key: value for key, value in optional_params.items() if key != "requestMetadata"}
),
litellm_params=litellm_params,
headers=headers,
)

View file

@ -1021,7 +1021,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
converse_config: Final = AmazonConverseConfig()
mapped_params = converse_config.map_openai_params(
non_default_params=optional_params,
non_default_params={k: v for k, v in optional_params.items() if k != "requestMetadata"},
optional_params={},
model=model,
drop_params=False,

View file

@ -10,6 +10,7 @@ from httpx import Response
from pydantic import TypeAdapter
from litellm._logging import verbose_logger
from litellm.constants import AWS_ERROR_TYPE_HEADER
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig, PassthroughStreamCollector
from litellm.types.llms.openai import ChatCompletionSystemMessage, ChatCompletionUserMessage
@ -17,6 +18,16 @@ from litellm.types.utils import ModelResponseStream
from ..base_aws_llm import BaseAWSLLM
from ..common_utils import BedrockError, BedrockEventStreamDecoderBase, BedrockModelInfo
from ..request_metadata import (
BEDROCK_REQUEST_METADATA_HEADER,
BedrockRequestMetadataViolation,
bedrock_request_metadata_headers,
bedrock_request_metadata_is_owned,
bedrock_request_metadata_violation,
caller_request_metadata_violations_are_dropped,
merge_bedrock_invoke_headers,
resolve_bedrock_request_metadata,
)
if TYPE_CHECKING:
from botocore.eventstream import EventStreamMessage
@ -28,10 +39,87 @@ if TYPE_CHECKING:
BEDROCK_STREAMING_ACTIONS: Final = frozenset({"invoke-with-response-stream", "converse-stream"})
_BEDROCK_CONVERSE_ACTIONS: Final = frozenset({"converse", "converse-stream"})
_BEDROCK_INVOKE_ACTIONS: Final = frozenset({"invoke", "invoke-with-response-stream"})
def _bedrock_endpoint_action(endpoint: str) -> str:
return endpoint.partition("?")[0].rstrip("/").rsplit("/", 1)[-1]
def is_bedrock_streaming_endpoint(endpoint: str) -> bool:
return endpoint.partition("?")[0].rstrip("/").rsplit("/", 1)[-1] in BEDROCK_STREAMING_ACTIONS
return _bedrock_endpoint_action(endpoint) in BEDROCK_STREAMING_ACTIONS
def _caller_request_metadata_violation(
action: str, body: Mapping[str, object]
) -> BedrockRequestMetadataViolation | None:
if action in _BEDROCK_INVOKE_ACTIONS and "requestMetadata" in body and body["requestMetadata"] is None:
return BedrockRequestMetadataViolation("type", "requestMetadata must be a dictionary")
return bedrock_request_metadata_violation(body.get("requestMetadata"))
def _aws_error_type(action: str, violation: BedrockRequestMetadataViolation) -> str:
if action in _BEDROCK_CONVERSE_ACTIONS and violation.kind == "type":
return "SerializationException"
return "ValidationException"
def _caller_request_metadata(action: str, body: Mapping[str, object], litellm_params: Mapping[str, object]) -> object:
violation: Final = (
None
if caller_request_metadata_violations_are_dropped(litellm_params.get("drop_params") is True)
else _caller_request_metadata_violation(action, body)
)
if violation is not None:
raise BedrockError(
status_code=400,
message=violation.message,
headers={AWS_ERROR_TYPE_HEADER: _aws_error_type(action, violation)},
)
return body.get("requestMetadata")
def _with_body_request_metadata(
action: str, body: Mapping[str, object], litellm_params: Mapping[str, object]
) -> Mapping[str, object]:
resolved: Final = resolve_bedrock_request_metadata(
litellm_params, caller_metadata=_caller_request_metadata(action, body, litellm_params)
)
without_caller: Final = {key: value for key, value in body.items() if key != "requestMetadata"}
return without_caller if resolved is None else {**without_caller, "requestMetadata": resolved}
def _without_owned_request_metadata_header(headers: Mapping[str, str]) -> Mapping[str, str]:
if not bedrock_request_metadata_is_owned():
return headers
return {name: value for name, value in headers.items() if name.lower() != BEDROCK_REQUEST_METADATA_HEADER.lower()}
def _with_request_metadata(
action: str | None,
headers: Mapping[str, str],
body: Mapping[str, object],
litellm_params: Mapping[str, object],
) -> tuple[Mapping[str, str], Mapping[str, object]]:
if action in _BEDROCK_CONVERSE_ACTIONS:
return headers, _with_body_request_metadata(action, body, litellm_params)
if action in _BEDROCK_INVOKE_ACTIONS and "requestMetadata" in body:
return _without_owned_request_metadata_header(headers), _with_body_request_metadata(
action, body, litellm_params
)
if action in _BEDROCK_INVOKE_ACTIONS:
return _with_invoke_request_metadata(headers, body, litellm_params)
return headers, body
def _with_invoke_request_metadata(
headers: Mapping[str, str],
body: Mapping[str, object],
litellm_params: Mapping[str, object],
) -> tuple[Mapping[str, str], Mapping[str, object]]:
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
return merge_bedrock_invoke_headers(dict(headers), (), metadata_headers, owned_names), body
_TEXT_ONLY_DELTA_FIELDS: Final = frozenset({"content", "role"})
@ -284,18 +372,24 @@ class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamD
def sign_request(
self,
headers: dict,
litellm_params: dict,
request_data: dict | None,
headers: dict[str, str],
litellm_params: dict[str, object],
request_data: dict[str, object] | None,
api_base: str,
model: str | None = None,
) -> tuple[dict, bytes | None]:
optional_params: Final = litellm_params.copy()
signed_headers, signed_body = _with_request_metadata(
action=_bedrock_endpoint_action(api_base) if bedrock_request_metadata_is_owned() else None,
headers=headers,
body=request_data or {},
litellm_params=litellm_params,
)
return self._sign_request(
service_name="bedrock",
headers=headers,
headers=dict(signed_headers),
optional_params=optional_params,
request_data=request_data or {},
request_data=dict(signed_body),
api_base=api_base,
model=model,
api_key=self.get_bedrock_bearer_token(optional_params),

View file

@ -8,17 +8,22 @@ sets ``litellm.bedrock_request_metadata_fields`` (``litellm_settings`` on the pr
Two properties are load-bearing for that billing record and are asserted by the tests:
proxy identity is resolved first so it can never be evicted by caller-supplied pairs, and the
whole ``user_api_key_`` prefix is reserved so a caller cannot write a proxy-authoritative
looking key. Values that break Bedrock's constraints are dropped rather than sanitised or
rejected, because an operator flipping this setting on must not turn a working request into a
400 and a silently rewritten attribution key is worse than an absent one.
looking key. Identity and ``spend_logs_metadata`` values that break Bedrock's constraints are
dropped rather than sanitised or rejected, because an operator flipping this setting on must not
turn a working request into a 400 and a silently rewritten attribution key is worse than an
absent one. For the same reason an Invoke header that would pass Bedrock's 8,500-character limit
keeps only the leading pairs that fit. A caller's own malformed ``requestMetadata`` is rejected with
a 400 unless ``drop_params`` is set, in which case its bad pairs are dropped the same way, with or
without the setting.
"""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from typing import Final
from collections.abc import Mapping, Sized
from dataclasses import dataclass
from typing import Final, Literal
import litellm
@ -26,15 +31,27 @@ BEDROCK_REQUEST_METADATA_HEADER: Final = "X-Amzn-Bedrock-Request-Metadata"
BEDROCK_REQUEST_METADATA_MAX_PAIRS: Final = 16
BEDROCK_REQUEST_METADATA_IDENTITY_PREFIX: Final = "user_api_key_"
BEDROCK_REQUEST_METADATA_CLIENT_FIELD: Final = "spend_logs_metadata"
BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS: Final = 8500
_METADATA_PARAM_NAMES: Final[tuple[str, ...]] = ("metadata", "litellm_metadata")
_KEY_PATTERN: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{1,256}$")
_VALUE_PATTERN: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{0,256}$")
_ALLOWED_CHARACTERS: Final = r"[a-zA-Z0-9\s:_@$#=/+,.-]"
_KEY_PATTERN: Final = re.compile(rf"{_ALLOWED_CHARACTERS}{{1,256}}", re.ASCII)
_VALUE_PATTERN: Final = re.compile(rf"{_ALLOWED_CHARACTERS}{{0,256}}", re.ASCII)
_OWNED_HEADER_NAMES: Final[frozenset[str]] = frozenset((BEDROCK_REQUEST_METADATA_HEADER.lower(),))
@dataclass(frozen=True, slots=True)
class BedrockRequestMetadataViolation:
kind: Literal["type", "constraint"]
message: str
def _is_forwardable(key: str, value: str) -> bool:
return _KEY_PATTERN.match(key) is not None and _VALUE_PATTERN.match(value) is not None
return _KEY_PATTERN.fullmatch(key) is not None and _VALUE_PATTERN.fullmatch(value) is not None
def _is_reserved(key: str) -> bool:
return key.strip().lower().startswith(BEDROCK_REQUEST_METADATA_IDENTITY_PREFIX)
def _text_pairs(source: object) -> tuple[tuple[str, str], ...]:
@ -99,13 +116,16 @@ def _client_pairs(
candidates: Final = tuple(
(key, value)
for key, value in (*_text_pairs(caller_metadata), *spend_logs_pairs)
if not key.startswith(BEDROCK_REQUEST_METADATA_IDENTITY_PREFIX) and _is_forwardable(key, value)
if not _is_reserved(key) and _is_forwardable(key, value)
)
return tuple(
pair
for index, pair in enumerate(candidates)
if pair[0] not in tuple(earlier for earlier, _ in candidates[:index])
)[:budget]
first_index: Final = {key: index for index, (key, _) in reversed(tuple(enumerate(candidates)))}
return tuple(pair for index, pair in enumerate(candidates) if first_index[pair[0]] == index)[:budget]
def _forwardable_caller_pairs(caller_metadata: object) -> tuple[tuple[str, str], ...]:
return tuple(pair for pair in _text_pairs(caller_metadata) if _is_forwardable(*pair))[
:BEDROCK_REQUEST_METADATA_MAX_PAIRS
]
def resolve_bedrock_request_metadata(
@ -113,17 +133,18 @@ def resolve_bedrock_request_metadata(
caller_metadata: object = None,
) -> dict[str, str] | None:
"""
Resolve the ``requestMetadata`` pairs to send to Bedrock, or ``None`` when the feature is
off or nothing survives Bedrock's constraints. The result is a plain dict because it is
written straight onto the Converse body, which Bedrock types as ``dict[str, str]``.
Resolve the ``requestMetadata`` pairs to send to Bedrock, or ``None`` when nothing survives
Bedrock's constraints. The result is a plain dict because it is written straight onto the
Converse body, which Bedrock types as ``dict[str, str]``.
``caller_metadata`` is any ``requestMetadata`` the caller passed explicitly. It has already
been validated (and rejected with a 400) by the Converse transformation, so it is only
filtered here for the reserved identity prefix and the remaining slot budget.
``caller_metadata`` is any ``requestMetadata`` the caller passed explicitly. Callers reject a
malformed one with a 400 unless ``drop_params`` is set, so here its pairs are only filtered for
Bedrock's rules, the reserved identity prefix, and the remaining slot budget. With the feature
off only Bedrock's rules apply, since no identity is written for a caller to forge.
"""
allowed_fields: Final = _allowed_fields()
if not allowed_fields:
return None
return dict(_forwardable_caller_pairs(caller_metadata)) or None
sources: Final = _metadata_sources(litellm_params)
identity: Final = _identity_pairs(sources, allowed_fields)
client: Final = _client_pairs(
@ -136,6 +157,36 @@ def resolve_bedrock_request_metadata(
return resolved or None
def _pair_violation(key: str, value: str) -> BedrockRequestMetadataViolation | None:
if _KEY_PATTERN.fullmatch(key) is None:
return BedrockRequestMetadataViolation(
"constraint", f"requestMetadata key {key!r} must be 1-256 characters of {_ALLOWED_CHARACTERS}"
)
if _VALUE_PATTERN.fullmatch(value) is None:
return BedrockRequestMetadataViolation(
"constraint", f"requestMetadata value for key {key!r} must be 0-256 characters of {_ALLOWED_CHARACTERS}"
)
return None
def bedrock_request_metadata_violation(caller_metadata: object) -> BedrockRequestMetadataViolation | None:
"""Why a caller's ``requestMetadata`` breaks Bedrock's rules, in the order Bedrock checks them: a
wrong JSON type before the pair count and character rules. ``None`` means absent or valid."""
text_pairs: Final = _text_pairs(caller_metadata)
entry_count: Final = len(caller_metadata) if isinstance(caller_metadata, Sized) else 0
if caller_metadata is None:
return None
if not isinstance(caller_metadata, Mapping):
return BedrockRequestMetadataViolation("type", "requestMetadata must be a dictionary")
if len(text_pairs) != entry_count:
return BedrockRequestMetadataViolation("type", "requestMetadata keys and values must be strings")
if len(text_pairs) > BEDROCK_REQUEST_METADATA_MAX_PAIRS:
return BedrockRequestMetadataViolation(
"constraint", f"requestMetadata can contain a maximum of {BEDROCK_REQUEST_METADATA_MAX_PAIRS} items"
)
return next((violation for key, value in text_pairs if (violation := _pair_violation(key, value))), None)
def bedrock_request_metadata_is_owned() -> bool:
"""
Whether the proxy OWNS the request-metadata field and header name for this request.
@ -149,25 +200,51 @@ def bedrock_request_metadata_is_owned() -> bool:
return bool(_allowed_fields())
def caller_request_metadata_violations_are_dropped(drop_params: bool) -> bool:
return drop_params or litellm.drop_params is True
def bedrock_request_metadata_is_rewritten(drop_params: bool) -> bool:
return bedrock_request_metadata_is_owned() or caller_request_metadata_violations_are_dropped(drop_params)
def _header_value(pairs: Mapping[str, str]) -> str:
return json.dumps(pairs, separators=(",", ":"))
def _fits_header(pairs: Mapping[str, str]) -> bool:
return len(_header_value(pairs)) <= BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS
def _longest_fitting_prefix(resolved: Mapping[str, str]) -> dict[str, str]:
pairs: Final = tuple(resolved.items())
return next(
(dict(pairs[:count]) for count in range(len(pairs), 0, -1) if _fits_header(dict(pairs[:count]))),
{},
)
def bedrock_request_metadata_headers(
litellm_params: Mapping[str, object] | None,
) -> tuple[frozenset[str], tuple[tuple[str, str], ...]]:
"""
The signed ``X-Amzn-Bedrock-Request-Metadata`` header for the Invoke paths, which have no
body field for request metadata.
The signed ``X-Amzn-Bedrock-Request-Metadata`` header for an Invoke request whose body carries
no ``requestMetadata`` of its own.
Returns the header names the proxy OWNS and, separately, the pairs to send. Ownership is
reported whenever forwarding is enabled, including when nothing resolves, because a caller
can suppress the resolver (omit the allow-listed fields, or send values that all fail
Bedrock's rules) and an owned-but-empty result must still evict the caller's header rather
than fall back to it.
than fall back to it. Only the leading pairs that fit Bedrock's header limit are sent, so
identity always survives and long ``spend_logs_metadata`` pairs drop off the end.
"""
if not bedrock_request_metadata_is_owned():
return frozenset(), ()
resolved: Final = resolve_bedrock_request_metadata(litellm_params)
if resolved is None:
fitting: Final = {} if resolved is None else _longest_fitting_prefix(resolved)
if not fitting:
return _OWNED_HEADER_NAMES, ()
return _OWNED_HEADER_NAMES, ((BEDROCK_REQUEST_METADATA_HEADER, json.dumps(resolved, separators=(",", ":"))),)
return _OWNED_HEADER_NAMES, ((BEDROCK_REQUEST_METADATA_HEADER, _header_value(fitting)),)
def merge_bedrock_invoke_headers(

View file

@ -1087,6 +1087,16 @@ def _guardrail_refusal_status(error: Exception) -> int | None:
return None
def _aws_error_type(error: Exception, handled: ProxyException) -> str | None:
"""The AWS error type to answer the handled error with: ValidationException for a guardrail block, else the type
the error carried, as LiteLLM's own Bedrock request checks set it. None when the shared handler changed the
guardrail block's status."""
refusal_status: Final = _guardrail_refusal_status(error)
if refusal_status is not None:
return "ValidationException" if str(refusal_status) == handled.code else None
return next((value for name, value in handled.headers.items() if name.lower() == AWS_ERROR_TYPE_HEADER), None)
def _bedrock_error_response(error: Exception, handled: HTTPException) -> Response | None:
"""Bedrock's own error response, for an error Bedrock answered the passthrough request with an AWS error
type."""
@ -1111,9 +1121,9 @@ async def bedrock_passthrough_error_response(
user_api_key_dict: UserAPIKeyAuth,
proxy_logging_obj: ProxyLoggingType,
) -> Response:
"""Run the shared error handling for a Bedrock passthrough request, then answer a guardrail block or an error
Bedrock returned the way Bedrock does, so an AWS SDK reads its code and message. Anything else is raised as the
shared handler raises it."""
"""Run the shared error handling for a Bedrock passthrough request, then answer a guardrail block, an error
Bedrock returned, or an error carrying an AWS error type the way Bedrock does, so an AWS SDK reads its code and
message. Anything else is raised as the shared handler raises it."""
try:
await processor.handle_llm_api_exception(
e=error,
@ -1126,9 +1136,9 @@ async def bedrock_passthrough_error_response(
raise
return response
except ProxyException as handled:
refusal_status: Final = _guardrail_refusal_status(error)
if refusal_status is not None and str(refusal_status) == handled.code:
raise AwsErrorTypeProxyException(handled, "ValidationException") from handled
aws_error_type: Final = _aws_error_type(error, handled)
if aws_error_type is not None:
raise AwsErrorTypeProxyException(handled, aws_error_type) from handled
raise

View file

@ -0,0 +1,316 @@
import base64
import json
import uuid
from urllib.parse import unquote
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import boto3
import pytest
import yaml
from botocore.config import Config
from botocore.exceptions import ClientError
from integration._support.budget import case_timeout_seconds
from integration._support.client import Gateway
from integration._support.process import graceful_stop_seconds, owned_proxy
from integration._support.upstream import _aws_event_frame
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
_MODEL_ID: Final = "anthropic.claude-sonnet-5-v1:0"
_EVENT_STREAM: Final = "application/vnd.amazon.eventstream"
_METADATA_HEADER: Final = "x-amzn-bedrock-request-metadata"
_HEADER_MAX_CHARS: Final = 8500
_LONG_SPEND_LOGS_METADATA: Final = tuple((f"{index:02d}".ljust(256, "k"), "\n" * 256) for index in range(12))
_CONVERSE_REPLY: Final[Mapping[str, JsonValue]] = {
"output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 5, "outputTokens": 1, "totalTokens": 6},
"metrics": {"latencyMs": 3},
}
_INVOKE_REPLY: Final[Mapping[str, JsonValue]] = {
"id": "msg_bedrock",
"type": "message",
"role": "assistant",
"model": "claude",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 5, "output_tokens": 1},
}
_CONVERSE_STREAM_FRAMES: Final = (
_aws_event_frame("messageStart", {"role": "assistant"}, "sc", "u"),
_aws_event_frame("messageStop", {"stopReason": "end_turn"}, "sc", "u"),
_aws_event_frame("metadata", {"usage": {"inputTokens": 5, "outputTokens": 1, "totalTokens": 6}}, "sc", "u"),
)
def _bedrock_peer(request: Request) -> Reply:
if request.target.endswith("/converse-stream"):
return Reply(chunks=_CONVERSE_STREAM_FRAMES, content_type=_EVENT_STREAM)
if request.target.endswith("/invoke-with-response-stream"):
chunk: Final = {"bytes": base64.b64encode(json.dumps(_INVOKE_REPLY).encode()).decode()}
return Reply(chunks=(_aws_event_frame("chunk", chunk, "sc", "u"),), content_type=_EVENT_STREAM)
if request.target.endswith("/converse"):
return Reply(body=json.dumps(_CONVERSE_REPLY).encode())
return Reply(body=json.dumps(_INVOKE_REPLY).encode())
def _messages(marker: str) -> Sequence[JsonValue]:
return [{"role": "user", "content": [{"text": marker}]}]
def _invoke_body(marker: str) -> Mapping[str, JsonValue]:
return {
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 16,
"messages": [{"role": "user", "content": marker}],
}
def _signed_header_names(request: Request) -> frozenset[str]:
return frozenset(request.headers["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";"))
def _header_value(pairs: tuple[tuple[str, str], ...]) -> str:
return json.dumps(dict(pairs), separators=(",", ":"))
def _send_with_caller_metadata(
runtime: object, action: str, model: str, marker: str, caller_metadata: Mapping[str, JsonValue]
) -> None:
if action == "invoke":
getattr(runtime, "invoke_model")(
modelId=model, body=json.dumps({**_invoke_body(marker), "requestMetadata": caller_metadata})
)
return
getattr(runtime, action.replace("-", "_"))(
modelId=model, messages=_messages(marker), requestMetadata=caller_metadata
)
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
wire: Wire
strict_model: str
dropping_model: str
chat_model: str
key: str
identity: Mapping[str, str]
long_key: str
long_identity: tuple[tuple[str, str], ...]
def runtime(self, key: str, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 # boto3 clients are untyped
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", key)
return boto3.client(
"bedrock-runtime",
endpoint_url=f"{str(self.proxy.client.base_url).rstrip('/')}/bedrock",
region_name="us-east-1",
config=Config(
read_timeout=case_timeout_seconds(),
retries={"total_max_attempts": 1},
parameter_validation=False,
),
)
def received(self, marker: str) -> tuple[Request, ...]:
return tuple(request for request in self.wire.drain() if marker.encode() in request.body)
@pytest.fixture(scope="module")
def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
suffix: Final = uuid.uuid4().hex[:12]
strict_model: Final = f"bedrock-metadata-{suffix}"
dropping_model: Final = f"bedrock-metadata-dropping-{suffix}"
chat_model: Final = f"bedrock-metadata-chat-{suffix}"
directory: Final = tmp_path_factory.mktemp("bedrock-request-metadata")
base_config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
with wire_server(_bedrock_peer) as wire:
deployment: Final = {
"model": f"bedrock/{_MODEL_ID}",
"api_base": wire.url,
"aws_access_key_id": "AKIASCRIPTEDPROVIDER",
"aws_secret_access_key": "scripted-secret",
"aws_region_name": "us-east-1",
"use_in_pass_through": True,
}
config: Final = {
**base_config,
"model_list": [
*base_config.get("model_list", []),
{"model_name": strict_model, "litellm_params": deployment},
{"model_name": dropping_model, "litellm_params": {**deployment, "drop_params": True}},
{
"model_name": chat_model,
"litellm_params": {**deployment, "model": f"bedrock/converse/{_MODEL_ID}", "drop_params": True},
},
],
"litellm_settings": {
**base_config.get("litellm_settings", {}),
"drop_params": False,
"bedrock_request_metadata_fields": [
"user_api_key_alias",
"user_api_key_team_alias",
"spend_logs_metadata",
],
},
}
config_path: Final = directory / "bedrock-request-metadata.yaml"
config_path.write_text(yaml.safe_dump(config))
with (
owned_proxy(session_gateway, directory, {}, config=config_path, workers=2) as proxy,
proxy.scenario() as scenario,
):
team_alias: Final = f"platform-{suffix}"
team_id: Final = scenario.team(team_alias=team_alias)
key_alias: Final = f"prod-key-{suffix}"
long_key_alias: Final = f"long-key-{suffix}"
yield Rig(
proxy=proxy,
wire=wire,
strict_model=strict_model,
dropping_model=dropping_model,
chat_model=chat_model,
key=scenario.key(
key_alias=key_alias,
team_id=team_id,
metadata={"spend_logs_metadata": {"agent": "triage-bot"}},
),
identity={
"user_api_key_alias": key_alias,
"user_api_key_team_alias": team_alias,
"agent": "triage-bot",
},
long_key=scenario.key(
key_alias=long_key_alias,
team_id=team_id,
metadata={"spend_logs_metadata": dict(_LONG_SPEND_LOGS_METADATA)},
),
long_identity=(
("user_api_key_alias", long_key_alias),
("user_api_key_team_alias", team_alias),
*_LONG_SPEND_LOGS_METADATA,
),
)
def test_boto3_passthrough_calls_carry_the_key_identity_in_signed_request_metadata(
rig: Rig, monkeypatch: pytest.MonkeyPatch
) -> None:
marker: Final = f"who-is-calling-{uuid.uuid4().hex}"
forged: Final = {"user_api_key_team_alias": "forged", "ticket": "T-42", "bad!": "x"}
runtime: Final = rig.runtime(rig.key, monkeypatch)
model: Final = rig.dropping_model
converse: Final = runtime.converse(modelId=model, messages=_messages(marker), requestMetadata=forged)
converse_stream: Final = [
next(iter(event))
for event in runtime.converse_stream(modelId=model, messages=_messages(marker), requestMetadata=forged)[
"stream"
]
]
invoke: Final = json.loads(
runtime.invoke_model(modelId=model, body=json.dumps(_invoke_body(marker)))["body"].read()
)
invoke_stream: Final = [
json.loads(event["chunk"]["bytes"])
for event in runtime.invoke_model_with_response_stream(modelId=model, body=json.dumps(_invoke_body(marker)))[
"body"
]
]
assert converse["output"] == _CONVERSE_REPLY["output"], converse
assert converse_stream == ["messageStart", "messageStop", "metadata"], converse_stream
assert invoke == _INVOKE_REPLY, invoke
assert invoke_stream == [_INVOKE_REPLY], invoke_stream
received: Final = rig.received(marker)
assert [request.target for request in received] == [
f"/model/{_MODEL_ID}/converse",
f"/model/{_MODEL_ID}/converse-stream",
f"/model/{_MODEL_ID}/invoke",
f"/model/{_MODEL_ID}/invoke-with-response-stream",
], received
converse_requests: Final = received[:2]
invoke_requests: Final = received[2:]
assert [json.loads(request.body) for request in converse_requests] == [
{"messages": _messages(marker), "requestMetadata": {**rig.identity, "ticket": "T-42"}},
{"messages": _messages(marker), "requestMetadata": {**rig.identity, "ticket": "T-42"}},
], [request.body for request in converse_requests]
assert [_METADATA_HEADER in request.headers for request in converse_requests] == [False, False]
assert [json.loads(request.body) for request in invoke_requests] == [_invoke_body(marker), _invoke_body(marker)]
assert [json.loads(request.headers[_METADATA_HEADER]) for request in invoke_requests] == [
rig.identity,
rig.identity,
], [request.headers for request in invoke_requests]
assert all(_METADATA_HEADER in _signed_header_names(request) for request in invoke_requests), [
request.headers["authorization"] for request in invoke_requests
]
def test_invoke_metadata_too_long_for_the_header_signs_the_leading_pairs_that_fit(
rig: Rig, monkeypatch: pytest.MonkeyPatch
) -> None:
marker: Final = f"long-key-{uuid.uuid4().hex}"
runtime: Final = rig.runtime(rig.long_key, monkeypatch)
invoke: Final = json.loads(
runtime.invoke_model(modelId=rig.strict_model, body=json.dumps(_invoke_body(marker)))["body"].read()
)
assert invoke == _INVOKE_REPLY, invoke
(request,) = rig.received(marker)
assert json.loads(request.body) == _invoke_body(marker)
header: Final = request.headers[_METADATA_HEADER]
sent_pairs: Final = len(json.loads(header))
assert tuple(json.loads(header).items()) == rig.long_identity[:sent_pairs], header
assert 2 <= sent_pairs < len(rig.long_identity), header
assert len(header) <= _HEADER_MAX_CHARS < len(_header_value(rig.long_identity[: sent_pairs + 1]))
assert _METADATA_HEADER in _signed_header_names(request), request.headers["authorization"]
@pytest.mark.parametrize(
("action", "caller_metadata", "error_code"),
[
pytest.param("converse", {"ticket": 42}, "SerializationException", id="converse-number-value"),
pytest.param("converse-stream", {"bad!": "x"}, "ValidationException", id="converse-stream-bad-key"),
pytest.param("invoke", {"bad!": "x"}, "ValidationException", id="invoke-bad-key"),
],
)
def test_malformed_caller_request_metadata_reaches_boto3_as_the_bedrock_error_without_an_upstream_call(
rig: Rig, monkeypatch: pytest.MonkeyPatch, action: str, caller_metadata: Mapping[str, JsonValue], error_code: str
) -> None:
marker: Final = f"malformed-{uuid.uuid4().hex}"
runtime: Final = rig.runtime(rig.key, monkeypatch)
with pytest.raises(ClientError) as raised:
_send_with_caller_metadata(runtime, action, rig.strict_model, marker, caller_metadata)
assert raised.value.response["Error"]["Code"] == error_code, raised.value.response
assert raised.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400, raised.value.response
assert rig.received(marker) == ()
def test_chat_on_a_dropping_converse_deployment_sends_only_the_valid_caller_pairs(rig: Rig) -> None:
marker: Final = f"chat-{uuid.uuid4().hex}"
response: Final = rig.proxy.client.post(
"/chat/completions",
json={
"model": rig.chat_model,
"messages": [{"role": "user", "content": marker}],
"requestMetadata": {"ticket": "T-42", "bad!": "x"},
},
headers={"Authorization": f"Bearer {rig.key}"},
timeout=case_timeout_seconds(),
)
assert response.status_code == 200, response.text
assert response.json()["choices"][0]["message"]["content"] == "ok", response.text
(request,) = rig.received(marker)
assert unquote(request.target) == f"/model/{_MODEL_ID}/converse", request.target
assert json.loads(request.body)["requestMetadata"] == {**rig.identity, "ticket": "T-42"}, request.body

View file

@ -115,3 +115,22 @@ def test_tool_config_injection_point_is_neither_placed_nor_credited(local_model_
assert [tool["toolSpec"]["name"] for tool in request["toolConfig"]["tools"]] == ["f"]
assert "cachePoint" not in json.dumps(request)
assert GATEWAY_INJECTED_CACHE_METADATA_KEY not in metadata
@pytest.mark.parametrize("value", ["b c", "bad!"], ids=["nbsp", "outside-bedrock-charset"])
def test_caller_request_metadata_is_ignored_because_invoke_model_has_no_field_for_it(local_model_cost_map, value):
caller_metadata = {"ticket": value}
optional_params = AmazonInvokeNovaConfig().map_openai_params(
non_default_params={"max_tokens": 8, "requestMetadata": caller_metadata},
optional_params={},
model=MODEL,
drop_params=False,
)
request = _transform_request(
messages=[{"role": "user", "content": "hi"}],
optional_params={**optional_params, "requestMetadata": caller_metadata},
)
assert "requestMetadata" not in request
assert request["inferenceConfig"] == {"maxTokens": 8}

View file

@ -4199,7 +4199,7 @@ def test_request_metadata_key_constraints():
# Test empty key
invalid_metadata = {"": "value"}
with pytest.raises(Exception, match=r"(?i)key length|empty"):
with pytest.raises(Exception, match=r"key '' must be 1-256 characters"):
config._transform_request(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
messages=messages,
@ -4241,6 +4241,18 @@ def test_request_metadata_value_constraints():
)
@pytest.mark.parametrize("metadata", [{"ticket": "a\u00a0b"}, {"a\u2003b": "ticket"}])
def test_request_metadata_rejects_whitespace_outside_ascii(metadata):
with pytest.raises(litellm.exceptions.BadRequestError, match=r"must be [01]-256 characters of"):
AmazonConverseConfig()._transform_request(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
messages=[{"role": "user", "content": "Hello!"}],
optional_params={"requestMetadata": metadata},
litellm_params={},
headers={},
)
def test_request_metadata_character_pattern():
"""Test character pattern validation for keys and values."""
config = AmazonConverseConfig()

View file

@ -1760,6 +1760,23 @@ class TestBedrockBatchNonChatEndpointRecords:
assert "metadata" not in model_input
assert "requestMetadata" not in model_input
def test_nova_record_request_metadata_stays_out_of_the_bedrock_body(self):
model_input = self._transform(
{
"custom_id": "4d",
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": self.NOVA_MODEL,
"messages": [{"role": "user", "content": "hi"}],
"requestMetadata": {"ticket": "b\u00a0c"},
},
}
)
assert "requestMetadata" not in model_input
assert model_input["messages"] == [{"role": "user", "content": [{"text": "hi"}]}]
@pytest.mark.parametrize(
"body",
[

View file

@ -17,6 +17,7 @@ import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.base_llm.passthrough.transformation import PassthroughStreamCollector
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.passthrough.transformation import (
BedrockPassthroughConfig,
is_bedrock_streaming_endpoint,
@ -898,3 +899,293 @@ def test_stream_collector_logs_nothing_for_an_unrecognized_endpoint():
collector.add(_event_frame("messageStart", {"role": "assistant"}))
assert collector.build_logged_response(_converse_stream_logging_obj()) is None
REQUEST_METADATA_HEADER = "x-amzn-bedrock-request-metadata"
REQUEST_METADATA_FIELDS = ["user_api_key_alias", "user_api_key_team_alias", "spend_logs_metadata"]
PROXY_IDENTITY = {
"user_api_key_alias": "prod-key",
"user_api_key_team_alias": "platform",
"spend_logs_metadata": {"agent": "triage-bot"},
}
EXPECTED_REQUEST_METADATA = {
"user_api_key_alias": "prod-key",
"user_api_key_team_alias": "platform",
"agent": "triage-bot",
}
CONVERSE_BODY = {"messages": [{"role": "user", "content": [{"text": "hi"}]}]}
INVOKE_BODY = {
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 8,
"messages": [{"role": "user", "content": "hi"}],
}
async def _sent_passthrough_request(
action: str, body: dict[str, object], identity: dict[str, object], **kwargs: object
) -> httpx.Request:
requests: Final[list[httpx.Request]] = []
def respond(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"ok": True}, request=request)
router: Final = litellm.Router(
model_list=[
{
"model_name": "bedrock-pt",
"litellm_params": {
"model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
"aws_access_key_id": "AKIAROUTERKEY",
"aws_secret_access_key": "router-secret",
"aws_region_name": "us-west-2",
},
}
]
)
await router.allm_passthrough_route(
model="bedrock-pt",
method="POST",
endpoint=f"/model/bedrock-pt/{action}",
data=body,
client=AsyncHTTPHandler(transport=httpx.MockTransport(respond)),
litellm_metadata=identity,
**kwargs,
)
return requests[0]
def _signed_header_names(request: httpx.Request) -> frozenset[str]:
return frozenset(request.headers["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";"))
@pytest.mark.asyncio
@pytest.mark.parametrize(
("action", "query_params"),
[("converse", None), ("converse-stream", None), ("converse/", None), ("converse", {"trace": "1"})],
)
async def test_converse_passthrough_signs_proxy_identity_into_request_metadata(
action: str, query_params: dict[str, str] | None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
caller_body: Final = {**CONVERSE_BODY, "requestMetadata": {"user_api_key_team_alias": "forged", "ticket": "T-42"}}
request: Final = await _sent_passthrough_request(
action, caller_body, PROXY_IDENTITY, request_query_params=query_params
)
assert json.loads(request.content) == {
**CONVERSE_BODY,
"requestMetadata": {**EXPECTED_REQUEST_METADATA, "ticket": "T-42"},
}
assert caller_body["requestMetadata"] == {"user_api_key_team_alias": "forged", "ticket": "T-42"}
assert REQUEST_METADATA_HEADER not in request.headers
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"])
async def test_invoke_passthrough_sends_proxy_identity_as_a_signed_header(
action: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
request: Final = await _sent_passthrough_request(
action,
INVOKE_BODY,
PROXY_IDENTITY,
request_headers={f"x-pass-{REQUEST_METADATA_HEADER}": '{"user_api_key_team_alias":"forged"}'},
)
assert json.loads(request.headers[REQUEST_METADATA_HEADER]) == EXPECTED_REQUEST_METADATA
assert REQUEST_METADATA_HEADER in _signed_header_names(request)
assert json.loads(request.content) == INVOKE_BODY
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"])
async def test_invoke_passthrough_keeps_caller_body_request_metadata_in_the_body(
action: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
caller_body: Final = {**INVOKE_BODY, "requestMetadata": {"user_api_key_team_alias": "forged", "ticket": "T-42"}}
request: Final = await _sent_passthrough_request(
action,
caller_body,
PROXY_IDENTITY,
request_headers={f"x-pass-{REQUEST_METADATA_HEADER.upper()}": '{"user_api_key_team_alias":"forged"}'},
)
assert json.loads(request.content) == {
**INVOKE_BODY,
"requestMetadata": {**EXPECTED_REQUEST_METADATA, "ticket": "T-42"},
}
assert REQUEST_METADATA_HEADER not in request.headers
@pytest.mark.asyncio
@pytest.mark.parametrize("setting", [None, []])
async def test_passthrough_requests_are_untouched_when_forwarding_is_off(
setting: list[str] | None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", setting)
caller_body: Final = {**CONVERSE_BODY, "requestMetadata": {"user_api_key_team_alias": "forged"}}
converse: Final = await _sent_passthrough_request("converse", caller_body, PROXY_IDENTITY)
invoke: Final = await _sent_passthrough_request("invoke", INVOKE_BODY, PROXY_IDENTITY)
assert json.loads(converse.content) == caller_body
assert REQUEST_METADATA_HEADER not in invoke.headers
@pytest.mark.asyncio
@pytest.mark.parametrize(("action", "body"), [("converse", CONVERSE_BODY), ("invoke", INVOKE_BODY)])
async def test_passthrough_drops_reserved_caller_keys_when_nothing_resolves(
action: str, body: dict[str, object], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
caller_body: Final = {**body, "requestMetadata": {"user_api_key_team_alias": "forged"}}
request: Final = await _sent_passthrough_request(action, caller_body, {})
assert json.loads(request.content) == body
assert REQUEST_METADATA_HEADER not in request.headers
MALFORMED_REQUEST_METADATA: Final = (
pytest.param(
{"user_api_key_team_alias": "forged", "bad!": "x"}, "key 'bad!' must be", "ValidationException", id="bad-key"
),
pytest.param({"ticket": "a\u00a0b"}, "value for key 'ticket' must be", "ValidationException", id="bad-value"),
pytest.param("ticket=T-42", "must be a dictionary", "SerializationException", id="not-a-dictionary"),
pytest.param({f"key_{index}": "v" for index in range(17)}, "maximum of 16 items", "ValidationException", id="17"),
pytest.param({"ticket": 42}, "must be strings", "SerializationException", id="non-string-value"),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["converse", "converse-stream"])
@pytest.mark.parametrize(("caller_metadata", "violation", "converse_error_type"), MALFORMED_REQUEST_METADATA)
async def test_malformed_converse_request_metadata_is_rejected_with_the_error_type_bedrock_returns(
action: str, caller_metadata: object, violation: str, converse_error_type: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
with pytest.raises(BedrockError, match=violation) as rejected:
await _sent_passthrough_request(action, {**CONVERSE_BODY, "requestMetadata": caller_metadata}, PROXY_IDENTITY)
assert rejected.value.status_code == 400
assert httpx.Headers(rejected.value.headers).get("x-amzn-errortype") == converse_error_type
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"])
@pytest.mark.parametrize(("caller_metadata", "violation", "converse_error_type"), MALFORMED_REQUEST_METADATA)
async def test_malformed_invoke_request_metadata_is_rejected_as_a_bedrock_validation_error(
action: str, caller_metadata: object, violation: str, converse_error_type: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
with pytest.raises(BedrockError, match=violation) as rejected:
await _sent_passthrough_request(action, {**INVOKE_BODY, "requestMetadata": caller_metadata}, PROXY_IDENTITY)
assert rejected.value.status_code == 400
assert httpx.Headers(rejected.value.headers).get("x-amzn-errortype") == "ValidationException"
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"])
async def test_invoke_null_request_metadata_is_rejected_like_bedrock_does(
action: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
with pytest.raises(BedrockError, match="must be a dictionary") as rejected:
await _sent_passthrough_request(action, {**INVOKE_BODY, "requestMetadata": None}, PROXY_IDENTITY)
assert rejected.value.status_code == 400
assert httpx.Headers(rejected.value.headers).get("x-amzn-errortype") == "ValidationException"
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["converse", "converse-stream"])
async def test_converse_null_request_metadata_is_treated_as_absent_like_bedrock_does(
action: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
request: Final = await _sent_passthrough_request(action, {**CONVERSE_BODY, "requestMetadata": None}, PROXY_IDENTITY)
assert json.loads(request.content) == {**CONVERSE_BODY, "requestMetadata": EXPECTED_REQUEST_METADATA}
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"])
async def test_invoke_metadata_too_long_for_the_header_sends_the_leading_pairs_that_fit(
action: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
long_pairs: Final = {f"{index:02d}".ljust(256, "k"): "\n" * 256 for index in range(12)}
resolved: Final = tuple(
{"user_api_key_alias": "prod-key", "user_api_key_team_alias": "platform", **long_pairs}.items()
)
request: Final = await _sent_passthrough_request(
action,
INVOKE_BODY,
{**PROXY_IDENTITY, "spend_logs_metadata": long_pairs},
request_headers={f"x-pass-{REQUEST_METADATA_HEADER}": '{"user_api_key_team_alias":"forged"}'},
)
header: Final = request.headers[REQUEST_METADATA_HEADER]
sent: Final = json.loads(header)
one_more_pair: Final = json.dumps(dict(resolved[: len(sent) + 1]), separators=(",", ":"))
assert sent == dict(resolved[: len(sent)])
assert len(header) <= 8500 < len(one_more_pair)
assert json.loads(request.content) == INVOKE_BODY
@pytest.mark.asyncio
@pytest.mark.parametrize(("action", "body"), [("converse", CONVERSE_BODY), ("invoke", INVOKE_BODY)])
@pytest.mark.parametrize("drop_globally", [True, False])
async def test_malformed_caller_request_metadata_is_dropped_when_params_are_dropped(
action: str, body: dict[str, object], drop_globally: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS)
monkeypatch.setattr(litellm, "drop_params", drop_globally)
caller_body: Final = {**body, "requestMetadata": {"user_api_key_team_alias": "forged", "bad!": "x"}}
dropped: Final = await _sent_passthrough_request(
action, caller_body, PROXY_IDENTITY, **({} if drop_globally else {"drop_params": True})
)
assert json.loads(dropped.content) == {**body, "requestMetadata": EXPECTED_REQUEST_METADATA}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("action", "body", "caller_metadata"),
[
("converse", CONVERSE_BODY, {"user_api_key_team_alias": "caller-team", "bad!": "x"}),
("invoke", INVOKE_BODY, {"user_api_key_team_alias": "caller-team", "bad!": "x"}),
("invoke", INVOKE_BODY, None),
],
)
@pytest.mark.parametrize("drop_globally", [True, False])
async def test_passthrough_body_is_sent_as_is_when_params_are_dropped_and_forwarding_is_off(
action: str,
body: dict[str, object],
caller_metadata: dict[str, str] | None,
drop_globally: bool,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", None)
monkeypatch.setattr(litellm, "drop_params", drop_globally)
caller_body: Final = {**body, "requestMetadata": caller_metadata}
sent: Final = await _sent_passthrough_request(
action, caller_body, PROXY_IDENTITY, **({} if drop_globally else {"drop_params": True})
)
assert json.loads(sent.content) == caller_body
assert REQUEST_METADATA_HEADER not in sent.headers

View file

@ -1,8 +1,10 @@
import asyncio
import json
from collections.abc import Callable, Mapping
from typing import Final
import pytest
from pydantic import TypeAdapter
import litellm
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
@ -18,6 +20,7 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran
)
from litellm.llms.bedrock.request_metadata import (
BEDROCK_REQUEST_METADATA_HEADER,
BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS,
BEDROCK_REQUEST_METADATA_MAX_PAIRS,
resolve_bedrock_request_metadata,
)
@ -128,6 +131,20 @@ def test_identity_survives_a_caller_filling_every_slot(
)
def test_a_key_sent_twice_keeps_the_caller_value_and_uses_one_slot(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS)
caller: Final = {
f"client_{index:02d}": "caller" for index in range(BEDROCK_REQUEST_METADATA_MAX_PAIRS - len(IDENTITY) - 1)
}
spend_logs: Final = {**{key: "spend" for key in caller}, "extra": "spend"}
resolved: Final = resolve_bedrock_request_metadata(
litellm_params("metadata", spend_logs_metadata=spend_logs, **IDENTITY), caller_metadata=caller
)
assert resolved == {**IDENTITY, **caller, "extra": "spend"}
@pytest.mark.parametrize(
"field_order",
[
@ -158,7 +175,14 @@ def test_a_field_repeated_in_the_allow_list_does_not_consume_a_client_slot(
@pytest.mark.parametrize("client_source", ["spend_logs_metadata", "requestMetadata"])
@pytest.mark.parametrize(
"forged_key",
["user_api_key_team_alias", "user_api_key_org_alias", "user_api_key_hash"],
[
"user_api_key_team_alias",
"user_api_key_org_alias",
"user_api_key_hash",
"USER_API_KEY_TEAM_ALIAS",
"User_Api_Key_Hash",
" user_api_key_user_id",
],
)
def test_caller_cannot_forge_or_shadow_a_reserved_identity_key(
forged_key,
@ -181,14 +205,21 @@ def test_caller_cannot_forge_or_shadow_a_reserved_identity_key(
assert "attacker-controlled" not in resolved.values()
def test_identity_violating_the_character_class_is_dropped_and_the_request_succeeds(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize(
"team_alias",
["O'Brien's team", "platform\u00a0team", "platform\u3000team", "x" * 256 + "\n"],
ids=["apostrophe", "no-break-space", "ideographic-space", "257-chars-ending-in-newline"],
)
def test_identity_violating_the_character_class_is_dropped_and_the_request_succeeds(
team_alias: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A team alias with an apostrophe must not turn a working request into a 400 the moment
an operator flips the setting on."""
an operator flips the setting on. Bedrock also rejects non-ASCII whitespace (checked live 2026-10-16)."""
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS)
params = litellm_params(
"metadata",
user_api_key_alias="prod-key",
user_api_key_team_alias="O'Brien's team",
user_api_key_team_alias=team_alias,
user_api_key_user_email="x" * 300,
)
@ -198,13 +229,54 @@ def test_identity_violating_the_character_class_is_dropped_and_the_request_succe
assert body["messages"]
def test_caller_supplied_violation_still_raises_bad_request(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize(
"caller_metadata",
[{"team": "O'Brien's team"}, {"k" * 256 + "\n": "v"}, {"team": "v" * 256 + "\n"}],
ids=["bad-character", "257-char-key-ending-in-newline", "257-char-value-ending-in-newline"],
)
def test_caller_supplied_violation_still_raises_bad_request(
caller_metadata: Mapping[str, str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS)
with pytest.raises(litellm.exceptions.BadRequestError):
converse_body(
litellm_params("metadata", **IDENTITY),
{"requestMetadata": {"team": "O'Brien's team"}},
converse_body(litellm_params("metadata", **IDENTITY), {"requestMetadata": caller_metadata})
@pytest.mark.parametrize("driver", CONVERSE_DRIVERS)
@pytest.mark.parametrize("drop_globally", [True, False])
@pytest.mark.parametrize(("fields", "forwarded_identity"), [(ALL_FIELDS, IDENTITY), (None, {})])
def test_caller_violations_are_dropped_like_the_passthrough_routes_when_params_are_dropped(
driver: Callable[[Mapping[str, object], Mapping[str, object]], Mapping[str, object]],
drop_globally: bool,
fields: list[str] | None,
forwarded_identity: Mapping[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", fields)
monkeypatch.setattr(litellm, "drop_params", drop_globally)
params: Final = {**litellm_params("metadata", **IDENTITY), **({} if drop_globally else {"drop_params": True})}
body: Final = driver(params, {"requestMetadata": {"team": "O'Brien's team", "ticket": "T-42"}})
assert body["requestMetadata"] == {**forwarded_identity, "ticket": "T-42"}
@pytest.mark.parametrize("fields", [ALL_FIELDS, None])
def test_mapped_caller_violations_are_dropped_when_params_are_dropped(
fields: list[str] | None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", fields)
caller: Final = {"team": "O'Brien's team", "ticket": "T-42"}
mapped: Final = AmazonConverseConfig().map_openai_params(
non_default_params={"requestMetadata": caller}, optional_params={}, model=MODEL, drop_params=True
)
assert mapped["requestMetadata"] == caller
with pytest.raises(litellm.exceptions.BadRequestError):
AmazonConverseConfig().map_openai_params(
non_default_params={"requestMetadata": caller}, optional_params={}, model=MODEL, drop_params=False
)
@ -320,6 +392,27 @@ def metadata_header_values(headers):
return [value for name, value in headers.items() if name.lower() == BEDROCK_REQUEST_METADATA_HEADER.lower()]
@pytest.mark.parametrize("driver", HEADER_DRIVERS)
def test_the_metadata_header_keeps_the_leading_pairs_that_fit_bedrocks_header_limit(
driver: Callable[[Mapping[str, str], Mapping[str, object]], Mapping[str, str]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS)
long_pairs: Final = {f"{index:02d}".ljust(256, "k"): "\n" * 256 for index in range(12)}
params: Final = litellm_params("metadata", spend_logs_metadata=long_pairs, **IDENTITY)
resolved: Final = tuple((resolve_bedrock_request_metadata(params) or {}).items())
headers: Final = driver({}, params)
(header,) = (value for name, value in headers.items() if name.lower() == BEDROCK_REQUEST_METADATA_HEADER.lower())
sent: Final = TypeAdapter(dict[str, str]).validate_json(header)
one_more_pair: Final = json.dumps(dict(resolved[: len(sent) + 1]), separators=(",", ":"))
assert len(header) <= BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS
assert tuple(sent.items()) == resolved[: len(sent)]
assert IDENTITY.items() <= sent.items()
assert len(one_more_pair) > BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS
def test_converse_still_sets_the_bearer_authorization_header(monkeypatch: pytest.MonkeyPatch):
"""Converse owns the metadata header now, and that must not disturb the api_key path its
validate_environment existed for. Closing the forgery hole cannot break authentication."""

View file

@ -33,6 +33,7 @@ from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException,
from litellm.proxy._types import AwsErrorTypeProxyException, ProxyException
from litellm.proxy.utils import ProxyLogging
from litellm.passthrough.main import raise_for_provider_status
from litellm.llms.bedrock.common_utils import BedrockError
from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
@ -2137,6 +2138,47 @@ class TestBedrockPassthroughAwsErrors:
assert exc_info.value.aws_error_type == "ValidationException"
assert (exc_info.value.error.message, exc_info.value.error.code) == (message, status_code)
@pytest.mark.asyncio
async def test_bedrock_request_check_is_raised_with_the_aws_error_type_it_carries(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "callbacks", [])
error: Final = BedrockError(
status_code=400,
message="requestMetadata can contain a maximum of 16 items",
headers={"x-amzn-errortype": "ValidationException"},
)
with pytest.raises(AwsErrorTypeProxyException) as exc_info:
await self._answer(error)
assert exc_info.value.aws_error_type == "ValidationException"
assert "requestMetadata can contain a maximum of 16 items" in exc_info.value.error.message
assert exc_info.value.error.code == "400"
@pytest.mark.asyncio
async def test_bedrock_error_without_an_aws_error_type_keeps_the_proxy_shape(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "callbacks", [])
with pytest.raises(ProxyException) as exc_info:
await self._answer(BedrockError(status_code=400, message="bad request"))
assert exc_info.value.code == "400"
@pytest.mark.asyncio
async def test_error_with_a_non_ascii_header_value_keeps_the_proxy_shape(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "callbacks", [])
error: Final = HTTPException(status_code=403, detail="model not allowed", headers={"x-model-group": "modèle"})
with pytest.raises(ProxyException) as exc_info:
await self._answer(error)
assert (exc_info.value.code, exc_info.value.headers.get("x-model-group")) == ("403", "modèle")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("error", "message", "code"),