feat(bedrock): forward LiteLLM identity and metadata into Bedrock requestMetadata (#36861)

Adds an opt-in operator allow-list, litellm_settings::bedrock_request_metadata_fields, that forwards LiteLLM key, team and end-user identity plus client spend_logs_metadata into Bedrock request metadata so Bedrock spend can be grouped in AWS Cost Explorer.

Covers all three Bedrock surfaces: the Converse body requestMetadata field, and a signed X-Amzn-Bedrock-Request-Metadata header on Invoke chat completions and on Invoke /v1/messages, where the header is the only viable leg.

The resolver reads both metadata variable names, reserves the whole user_api_key_ prefix against caller-supplied keys, caps the client slot budget explicitly at 16 minus the reserved count, and drops rather than rejects auto-injected values that violate Bedrock constraints. Caller-supplied requestMetadata keeps its existing 400 semantics.

The request-metadata field and header are proxy-owned whenever forwarding is enabled. A caller-supplied value, reachable through the generic extra_headers passthrough, is dropped unconditionally and compared case-insensitively, and is replaced only by the proxy's own value, so identity in the AWS billing record cannot be forged. Absence of a resolved value still means absence on the wire rather than a fallback to the caller's. The guardrail headers keep their existing no-displace behaviour.
This commit is contained in:
Yassin Kortam 2026-08-17 11:27:46 -07:00 committed by GitHub
parent 1139012b45
commit ee08b63657
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 673 additions and 13 deletions

View file

@ -26,6 +26,7 @@ def _dev_env_hot_reload_enabled() -> bool:
if os.getenv("LITELLM_MODE", "DEV") == "DEV":
_dotenv.load_dotenv(override=_dev_env_hot_reload_enabled())
from collections.abc import Sequence
from typing import (
Any,
Callable,
@ -217,6 +218,9 @@ add_user_information_to_llm_headers: Optional[bool] = (
overwrite_user_with_key_hash: bool = (
False # force the outgoing `user` param to the hashed api key, so providers see a stable, tamper-proof id
)
bedrock_request_metadata_fields: Optional[Sequence[str]] = (
None # allow-list of `user_api_key_*` fields (+ `spend_logs_metadata`) sent as Bedrock `requestMetadata`
)
store_audit_logs = False # Enterprise feature, allow users to see audit logs
skip_system_message_in_guardrail: bool = False
skip_tool_message_in_guardrail: bool = False

View file

@ -39,6 +39,12 @@ from litellm.llms.anthropic.chat.transformation import (
AnthropicConfig,
)
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
bedrock_request_metadata_is_owned,
merge_bedrock_invoke_headers,
resolve_bedrock_request_metadata,
)
from litellm.types.llms.bedrock import *
from litellm.types.llms.openai import (
AllMessageValues,
@ -1652,6 +1658,13 @@ class AmazonConverseConfig(BaseConfig):
user_continue_message=litellm_params.pop("user_continue_message", None),
)
request_metadata: Final = resolve_bedrock_request_metadata(
litellm_params=litellm_params, caller_metadata=_data.get("requestMetadata")
)
if bedrock_request_metadata_is_owned():
_data.pop("requestMetadata", None)
if request_metadata is not None:
_data["requestMetadata"] = request_metadata
data: Final[RequestObject] = {"messages": bedrock_messages, **_data}
return data
@ -1705,6 +1718,13 @@ class AmazonConverseConfig(BaseConfig):
user_continue_message=litellm_params.pop("user_continue_message", None),
)
request_metadata: Final = resolve_bedrock_request_metadata(
litellm_params=litellm_params, caller_metadata=_data.get("requestMetadata")
)
if bedrock_request_metadata_is_owned():
_data.pop("requestMetadata", None)
if request_metadata is not None:
_data["requestMetadata"] = request_metadata
data: Final[RequestObject] = {"messages": bedrock_messages, **_data}
return data
@ -2258,7 +2278,8 @@ class AmazonConverseConfig(BaseConfig):
) -> dict:
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
return merge_bedrock_invoke_headers(headers, (), metadata_headers, owned_names)
def should_fake_stream(
self,

View file

@ -13,6 +13,10 @@ import httpx
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
merge_bedrock_invoke_headers,
)
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm.passthrough.utils import CommonUtils
from litellm.types.llms.openai import AllMessageValues
@ -169,9 +173,12 @@ class AmazonBedrockOpenAIConfig(OpenAIGPTConfig, BaseAWSLLM):
"""
Validate the environment and return headers.
For Bedrock, we don't need Bearer token auth since we use AWS SigV4.
For Bedrock, we don't need Bearer token auth since we use AWS SigV4. This path signs the
same ``/model/{id}/invoke`` endpoint as ``AmazonInvokeConfig``, so it owns the request
metadata header on the same terms rather than letting a caller supply it.
"""
return headers
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
return merge_bedrock_invoke_headers(headers, (), metadata_headers, owned_names)
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BedrockError:
"""Return the appropriate error class for Bedrock."""

View file

@ -20,6 +20,10 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.bedrock.chat.invoke_handler import make_call, make_sync_call
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
merge_bedrock_invoke_headers,
)
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
HTTPHandler,
@ -417,15 +421,13 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
api_base: str | None = None,
) -> dict:
raw_guardrail_config: Final = optional_params.pop("guardrailConfig", None)
if raw_guardrail_config is None:
return headers
existing_header_names: Final = frozenset(name.lower() for name in headers)
guardrail_headers: Final = {
name: value
for name, value in _bedrock_invoke_guardrail_headers(raw_guardrail_config).items()
if name.lower() not in existing_header_names
}
return {**headers, **guardrail_headers}
guardrail_headers: Final = (
()
if raw_guardrail_config is None
else tuple(_bedrock_invoke_guardrail_headers(raw_guardrail_config).items())
)
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
return merge_bedrock_invoke_headers(headers, guardrail_headers, metadata_headers, owned_names)
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
return BedrockError(status_code=status_code, message=error_message)

View file

@ -37,6 +37,10 @@ from litellm.llms.bedrock.common_utils import (
normalize_tool_input_schema_types_for_bedrock_invoke,
pop_bedrock_invoke_output_config_format,
)
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
merge_bedrock_invoke_headers,
)
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_TOOL_SEARCH_BETA_HEADER,
@ -89,7 +93,8 @@ class AmazonAnthropicClaudeMessagesConfig(
api_key: str | None = None,
api_base: str | None = None,
) -> tuple[dict, str | None]:
return headers, api_base
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
return merge_bedrock_invoke_headers(headers, (), metadata_headers, owned_names), api_base
def sign_request(
self,

View file

@ -0,0 +1,199 @@
"""
Resolve AWS Bedrock ``requestMetadata`` from LiteLLM proxy identity and caller metadata.
Bedrock attaches request metadata to CloudTrail records and to the dimension AWS Cost
Explorer groups on, so everything here is opt-in: nothing is forwarded unless the operator
sets ``litellm.bedrock_request_metadata_fields`` (``litellm_settings`` on the proxy).
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.
"""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from typing import Final
import litellm
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"
_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}$")
_OWNED_HEADER_NAMES: Final[frozenset[str]] = frozenset((BEDROCK_REQUEST_METADATA_HEADER.lower(),))
def _is_forwardable(key: str, value: str) -> bool:
return _KEY_PATTERN.match(key) is not None and _VALUE_PATTERN.match(value) is not None
def _text_pairs(source: object) -> tuple[tuple[str, str], ...]:
if not isinstance(source, Mapping):
return ()
return tuple((key, value) for key, value in source.items() if isinstance(key, str) and isinstance(value, str))
def _allowed_fields() -> tuple[str, ...]:
"""
The operator allow-list, deduplicated so a field repeated in config cannot consume a second
reserved slot and shrink the client budget for nothing. First occurrence wins, which keeps
the operator's declared precedence intact.
"""
configured: Final[object] = litellm.bedrock_request_metadata_fields
if not isinstance(configured, (list, tuple)):
return ()
fields: Final = tuple(str(field) for field in configured)
return tuple(field for index, field in enumerate(fields) if field not in fields[:index])
def _metadata_sources(litellm_params: Mapping[str, object] | None) -> tuple[Mapping[str, object], ...]:
"""``metadata`` on /v1/chat/completions, ``litellm_metadata`` on the LITELLM_METADATA_ROUTES."""
if litellm_params is None:
return ()
return tuple(
source
for name in _METADATA_PARAM_NAMES
for source in (litellm_params.get(name),)
if isinstance(source, Mapping)
)
def _identity_pairs(
sources: tuple[Mapping[str, object], ...],
allowed_fields: tuple[str, ...],
) -> tuple[tuple[str, str], ...]:
return tuple(
(field, value)
for field in allowed_fields
if field.startswith(BEDROCK_REQUEST_METADATA_IDENTITY_PREFIX)
for value in (_first_text(sources, field),)
if value is not None and _is_forwardable(field, value)
)[:BEDROCK_REQUEST_METADATA_MAX_PAIRS]
def _first_text(sources: tuple[Mapping[str, object], ...], field: str) -> str | None:
return next((value for source in sources if isinstance(value := source.get(field), str)), None)
def _client_pairs(
sources: tuple[Mapping[str, object], ...],
allowed_fields: tuple[str, ...],
caller_metadata: object,
budget: int,
) -> tuple[tuple[str, str], ...]:
spend_logs_pairs: Final = (
tuple(pair for source in sources for pair in _text_pairs(source.get(BEDROCK_REQUEST_METADATA_CLIENT_FIELD)))
if BEDROCK_REQUEST_METADATA_CLIENT_FIELD in allowed_fields
else ()
)
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)
)
return tuple(
pair
for index, pair in enumerate(candidates)
if pair[0] not in tuple(earlier for earlier, _ in candidates[:index])
)[:budget]
def resolve_bedrock_request_metadata(
litellm_params: Mapping[str, object] | None,
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]``.
``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.
"""
allowed_fields: Final = _allowed_fields()
if not allowed_fields:
return None
sources: Final = _metadata_sources(litellm_params)
identity: Final = _identity_pairs(sources, allowed_fields)
client: Final = _client_pairs(
sources=sources,
allowed_fields=allowed_fields,
caller_metadata=caller_metadata,
budget=BEDROCK_REQUEST_METADATA_MAX_PAIRS - len(identity),
)
resolved: Final = {key: value for key, value in (*identity, *client)}
return resolved or None
def bedrock_request_metadata_is_owned() -> bool:
"""
Whether the proxy OWNS the request-metadata field and header name for this request.
Ownership follows the operator's opt-in alone, never whether anything resolved, because a
caller can suppress the resolver by omitting the allow-listed fields or by sending values
that all fail Bedrock's rules. Owned-but-empty has to mean "absent on the wire" rather than
"fall back to whatever the caller supplied", or the reserved-prefix guarantee is bypassable
by anyone who can make the resolver produce nothing.
"""
return bool(_allowed_fields())
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.
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.
"""
if not bedrock_request_metadata_is_owned():
return frozenset(), ()
resolved: Final = resolve_bedrock_request_metadata(litellm_params)
if resolved is None:
return _OWNED_HEADER_NAMES, ()
return _OWNED_HEADER_NAMES, ((BEDROCK_REQUEST_METADATA_HEADER, json.dumps(resolved, separators=(",", ":"))),)
def merge_bedrock_invoke_headers(
headers: dict[str, str],
caller_owned: tuple[tuple[str, str], ...],
proxy_owned: tuple[tuple[str, str], ...],
proxy_owned_names: frozenset[str],
) -> dict[str, str]:
"""
Merge the ``X-Amzn-*`` headers the Invoke paths derive from params.
``caller_owned`` (the guardrail headers) defers to a header the caller already set, which is
the long-standing behaviour for those. ``proxy_owned_names`` are dropped from the caller's
headers unconditionally and re-supplied only from ``proxy_owned``, because those names carry
proxy-authenticated identity into an AWS billing record that the caller must not be able to
write. Names are compared case-insensitively so a caller cannot leave a second spelling in
the dict and let the transport pick the winner.
"""
if not caller_owned and not proxy_owned and not proxy_owned_names:
return headers
existing_names: Final = frozenset(name.lower() for name in headers)
return {
name: value
for name, value in (
*((n, v) for n, v in headers.items() if n.lower() not in proxy_owned_names),
*((n, v) for n, v in caller_owned if n.lower() not in existing_names),
*proxy_owned,
)
}

View file

@ -0,0 +1,422 @@
import asyncio
import json
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath("../../../.."))
import litellm
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import (
AmazonBedrockOpenAIConfig,
)
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import (
AmazonInvokeConfig,
)
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
)
from litellm.llms.bedrock.request_metadata import (
BEDROCK_REQUEST_METADATA_HEADER,
BEDROCK_REQUEST_METADATA_MAX_PAIRS,
resolve_bedrock_request_metadata,
)
MODEL = "anthropic.claude-3-5-sonnet-20240620-v1:0"
MESSAGES = [{"role": "user", "content": "hi"}]
ALL_FIELDS = [
"user_api_key_alias",
"user_api_key_team_alias",
"user_api_key_user_email",
"spend_logs_metadata",
]
IDENTITY = {"user_api_key_alias": "prod-key", "user_api_key_team_alias": "platform"}
@pytest.fixture(autouse=True)
def reset_setting():
previous = litellm.bedrock_request_metadata_fields
yield
litellm.bedrock_request_metadata_fields = previous
def litellm_params(metadata_key, **metadata):
return {metadata_key: dict(metadata)}
def converse_body(litellm_params_value, optional_params=None):
return AmazonConverseConfig()._transform_request(
model=MODEL,
messages=MESSAGES,
optional_params=dict(optional_params or {}),
litellm_params=dict(litellm_params_value),
)
def converse_body_async(litellm_params_value, optional_params=None):
"""The proxy serves completions through the async transform, so every rule asserted against
the sync body has to be asserted against this one too or half the product is untested."""
return asyncio.run(
AmazonConverseConfig()._async_transform_request(
model=MODEL,
messages=MESSAGES,
optional_params=dict(optional_params or {}),
litellm_params=dict(litellm_params_value),
)
)
CONVERSE_DRIVERS = [converse_body, converse_body_async]
@pytest.mark.parametrize("setting", [None, []])
def test_feature_off_by_default_leaves_body_and_headers_untouched(setting):
litellm.bedrock_request_metadata_fields = setting
params = litellm_params("metadata", spend_logs_metadata={"team": "x"}, **IDENTITY)
assert "requestMetadata" not in converse_body(params)
assert BEDROCK_REQUEST_METADATA_HEADER not in AmazonInvokeConfig().validate_environment(
headers={}, model=MODEL, messages=MESSAGES, optional_params={}, litellm_params=dict(params)
)
messages_headers, _ = AmazonAnthropicClaudeMessagesConfig().validate_anthropic_messages_environment(
headers={}, model=MODEL, messages=MESSAGES, optional_params={}, litellm_params=dict(params)
)
assert BEDROCK_REQUEST_METADATA_HEADER not in messages_headers
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
def test_resolver_reads_both_metadata_variable_names(metadata_key):
"""`/v1/chat/completions` populates `metadata`; the LITELLM_METADATA_ROUTES populate
`litellm_metadata`. Reading only one silently forwards nothing on the other route."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
params = litellm_params(metadata_key, spend_logs_metadata={"cost_center": "cc-1"}, **IDENTITY)
assert converse_body(params)["requestMetadata"] == {**IDENTITY, "cost_center": "cc-1"}
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
def test_invoke_messages_header_reads_both_metadata_variable_names(metadata_key):
litellm.bedrock_request_metadata_fields = ALL_FIELDS
params = litellm_params(metadata_key, **IDENTITY)
headers, _ = AmazonAnthropicClaudeMessagesConfig().validate_anthropic_messages_environment(
headers={}, model=MODEL, messages=MESSAGES, optional_params={}, litellm_params=params
)
assert json.loads(headers[BEDROCK_REQUEST_METADATA_HEADER]) == IDENTITY
@pytest.mark.parametrize("reverse_client_keys", [False, True])
@pytest.mark.parametrize("field_order", [ALL_FIELDS, list(reversed(ALL_FIELDS))])
@pytest.mark.parametrize("client_source", ["spend_logs_metadata", "requestMetadata"])
def test_identity_survives_a_caller_filling_every_slot(reverse_client_keys, field_order, client_source):
"""A caller sending 16 keys of its own must not evict the identity the feature exists to
produce. Driven over every input ordering so the invariant is not an accident of one."""
litellm.bedrock_request_metadata_fields = field_order
client_keys = [f"client_{index:02d}" for index in range(BEDROCK_REQUEST_METADATA_MAX_PAIRS)]
client_pairs = {key: "v" for key in (reversed(client_keys) if reverse_client_keys else client_keys)}
if client_source == "spend_logs_metadata":
params, optional_params = litellm_params("metadata", spend_logs_metadata=client_pairs, **IDENTITY), {}
else:
params, optional_params = litellm_params("metadata", **IDENTITY), {"requestMetadata": client_pairs}
resolved = converse_body(params, optional_params)["requestMetadata"]
assert len(resolved) == BEDROCK_REQUEST_METADATA_MAX_PAIRS
for key, value in IDENTITY.items():
assert resolved[key] == value
assert len([key for key in resolved if key.startswith("client_")]) == (
BEDROCK_REQUEST_METADATA_MAX_PAIRS - len(IDENTITY)
)
@pytest.mark.parametrize(
"field_order",
[
["user_api_key_alias", "user_api_key_alias", "user_api_key_team_alias", "spend_logs_metadata"],
["user_api_key_alias", "user_api_key_team_alias", "user_api_key_alias", "spend_logs_metadata"],
["user_api_key_alias", "user_api_key_team_alias", "spend_logs_metadata", "user_api_key_team_alias"],
],
)
def test_a_field_repeated_in_the_allow_list_does_not_consume_a_client_slot(field_order):
"""An operator repeating a field in YAML must not inflate the reserved count and shrink the
client budget. Asserts the client keys that should have fitted actually reach the wire, since
asserting only that identity survives passes with or without the deduplication."""
litellm.bedrock_request_metadata_fields = field_order
client_keys = [f"client_{index:02d}" for index in range(BEDROCK_REQUEST_METADATA_MAX_PAIRS - 1)]
params = litellm_params("metadata", spend_logs_metadata={key: "v" for key in client_keys}, **IDENTITY)
resolved = converse_body(params)["requestMetadata"]
expected_client_slots = BEDROCK_REQUEST_METADATA_MAX_PAIRS - len(IDENTITY)
assert resolved == {**IDENTITY, **{key: "v" for key in client_keys[:expected_client_slots]}}
assert len(resolved) == BEDROCK_REQUEST_METADATA_MAX_PAIRS
assert client_keys[expected_client_slots - 1] in resolved
@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"],
)
def test_caller_cannot_forge_or_shadow_a_reserved_identity_key(forged_key, client_source):
"""`user_api_key_org_alias` and `user_api_key_hash` are names the proxy does not set here,
so an exact-key reservation would let the forged value through under a name that reads as
proxy-authoritative in the AWS billing record."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
forged = {forged_key: "attacker-controlled"}
if client_source == "spend_logs_metadata":
params, optional_params = litellm_params("metadata", spend_logs_metadata=forged, **IDENTITY), {}
else:
params, optional_params = litellm_params("metadata", **IDENTITY), {"requestMetadata": forged}
resolved = converse_body(params, optional_params)["requestMetadata"]
assert resolved == IDENTITY
assert "attacker-controlled" not in resolved.values()
def test_identity_violating_the_character_class_is_dropped_and_the_request_succeeds():
"""A team alias with an apostrophe must not turn a working request into a 400 the moment
an operator flips the setting on."""
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_user_email="x" * 300,
)
body = converse_body(params)
assert body["requestMetadata"] == {"user_api_key_alias": "prod-key"}
assert body["messages"]
def test_caller_supplied_violation_still_raises_bad_request():
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"}},
)
def test_non_string_and_absent_identity_values_are_dropped():
litellm.bedrock_request_metadata_fields = ALL_FIELDS + ["user_api_key_spend"]
params = litellm_params("metadata", user_api_key_alias="prod-key", user_api_key_spend=1.25)
assert converse_body(params)["requestMetadata"] == {"user_api_key_alias": "prod-key"}
def test_email_is_separately_opt_in():
"""PII crossing into CloudTrail only when the operator names the field."""
identity_with_email = {**IDENTITY, "user_api_key_user_email": "owner@example.com"}
litellm.bedrock_request_metadata_fields = ["user_api_key_alias", "user_api_key_team_alias"]
assert (
"user_api_key_user_email"
not in converse_body(litellm_params("metadata", **identity_with_email))["requestMetadata"]
)
litellm.bedrock_request_metadata_fields = ALL_FIELDS
assert converse_body(litellm_params("metadata", **identity_with_email))["requestMetadata"] == identity_with_email
def test_resolver_returns_none_when_nothing_survives():
litellm.bedrock_request_metadata_fields = ALL_FIELDS
assert resolve_bedrock_request_metadata(litellm_params=None) is None
assert resolve_bedrock_request_metadata(litellm_params={"metadata": {"unrelated": "x"}}) is None
def test_invoke_header_is_json_encoded_and_signed():
litellm.bedrock_request_metadata_fields = ALL_FIELDS
params = litellm_params("metadata", spend_logs_metadata={"cost_center": "cc-1"}, **IDENTITY)
headers = AmazonInvokeConfig().validate_environment(
headers={"anthropic-version": "bedrock-2023-05-31"},
model=MODEL,
messages=MESSAGES,
optional_params={},
litellm_params=params,
)
assert json.loads(headers[BEDROCK_REQUEST_METADATA_HEADER]) == {**IDENTITY, "cost_center": "cc-1"}
signed = BaseAWSLLM()._filter_headers_for_aws_signature(headers)
assert BEDROCK_REQUEST_METADATA_HEADER in signed
assert "anthropic-version" not in signed
def test_a_caller_supplied_guardrail_header_still_wins():
"""The no-displace rule is deliberate for the guardrail headers and must survive the
request-metadata header becoming proxy-owned."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
headers = AmazonInvokeConfig().validate_environment(
headers={"X-Amzn-Bedrock-GuardrailIdentifier": "caller-set"},
model=MODEL,
messages=MESSAGES,
optional_params={"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "DRAFT"}},
litellm_params=litellm_params("metadata", **IDENTITY),
)
assert headers["X-Amzn-Bedrock-GuardrailIdentifier"] == "caller-set"
assert headers["X-Amzn-Bedrock-GuardrailVersion"] == "DRAFT"
FORGED = '{"user_api_key_alias":"FORGED-KEY","user_api_key_team_alias":"FORGED-TEAM"}'
def invoke_headers(caller_headers, params, optional_params=None):
return AmazonInvokeConfig().validate_environment(
headers=dict(caller_headers),
model=MODEL,
messages=MESSAGES,
optional_params=dict(optional_params or {}),
litellm_params=dict(params),
)
def messages_headers(caller_headers, params):
resolved, _ = AmazonAnthropicClaudeMessagesConfig().validate_anthropic_messages_environment(
headers=dict(caller_headers),
model=MODEL,
messages=MESSAGES,
optional_params={},
litellm_params=dict(params),
)
return resolved
def openai_invoke_headers(caller_headers, params):
return AmazonBedrockOpenAIConfig().validate_environment(
headers=dict(caller_headers),
model=MODEL,
messages=MESSAGES,
optional_params={},
litellm_params=dict(params),
)
def converse_headers(caller_headers, params):
return AmazonConverseConfig().validate_environment(
headers=dict(caller_headers),
model=MODEL,
messages=MESSAGES,
optional_params={},
litellm_params=dict(params),
)
HEADER_DRIVERS = [invoke_headers, messages_headers, openai_invoke_headers, converse_headers]
def metadata_header_values(headers):
return [value for name, value in headers.items() if name.lower() == BEDROCK_REQUEST_METADATA_HEADER.lower()]
def test_converse_still_sets_the_bearer_authorization_header():
"""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."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
headers = AmazonConverseConfig().validate_environment(
headers={},
model=MODEL,
messages=MESSAGES,
optional_params={},
litellm_params=dict(litellm_params("metadata", **IDENTITY)),
api_key="sk-converse-bearer",
)
assert headers["Authorization"] == "Bearer sk-converse-bearer"
assert metadata_header_values(headers) == [json.dumps(IDENTITY, separators=(",", ":"))]
@pytest.mark.parametrize("driver", HEADER_DRIVERS)
@pytest.mark.parametrize(
"caller_header_name",
[BEDROCK_REQUEST_METADATA_HEADER, BEDROCK_REQUEST_METADATA_HEADER.lower(), "x-AMZN-bedrock-Request-METADATA"],
)
def test_a_caller_cannot_forge_the_request_metadata_header(driver, caller_header_name):
"""`extra_headers` puts caller-supplied names into the same dict the proxy merges into, so a
deferring merge would sign the caller's forged identity into the AWS billing record. Every
spelling must lose, or a second variant is left for the transport to choose between."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
headers = driver({caller_header_name: FORGED}, litellm_params("metadata", **IDENTITY))
values = metadata_header_values(headers)
assert values == [json.dumps(IDENTITY, separators=(",", ":"))]
assert "FORGED" not in json.dumps(headers)
@pytest.mark.parametrize("driver", HEADER_DRIVERS)
def test_a_caller_cannot_forge_the_header_when_the_resolver_yields_nothing(driver):
"""Forwarding enabled but nothing resolvable, which a caller can arrange by supplying values
that all fail Bedrock's rules. Owned-but-empty must mean no header on the wire, never a
fallback to the caller's."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
unresolvable = litellm_params("metadata", user_api_key_alias="O'Brien's key", user_api_key_team_alias="x" * 300)
headers = driver({BEDROCK_REQUEST_METADATA_HEADER: FORGED}, unresolvable)
assert metadata_header_values(headers) == []
assert "FORGED" not in json.dumps(headers)
@pytest.mark.parametrize("driver", CONVERSE_DRIVERS)
@pytest.mark.parametrize(
"forged_key",
["user_api_key_team_alias", "user_api_key_org_alias", "user_api_key_hash"],
)
def test_a_caller_cannot_keep_reserved_body_keys_when_the_resolver_yields_nothing(forged_key, driver):
"""The Converse body has the same fail-open shape as the header: with forwarding on and
nothing resolvable, leaving the caller's `requestMetadata` in place would keep their
reserved-prefix keys on the wire. Owned-but-empty must remove the field outright."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
body = driver(litellm_params("metadata"), {"requestMetadata": {forged_key: "FORGED"}})
assert "requestMetadata" not in body
assert "FORGED" not in json.dumps(body)
@pytest.mark.parametrize("driver", CONVERSE_DRIVERS)
def test_benign_caller_body_metadata_still_survives_when_no_identity_resolves(driver):
"""Removing the field must be scoped to the reserved keys being the only thing left, not a
blanket drop of the caller's own attribution pairs."""
litellm.bedrock_request_metadata_fields = ALL_FIELDS
body = driver(
litellm_params("metadata"),
{"requestMetadata": {"cost_center": "cc-9", "user_api_key_team_alias": "FORGED"}},
)
assert body["requestMetadata"] == {"cost_center": "cc-9"}
@pytest.mark.parametrize("driver", CONVERSE_DRIVERS)
def test_caller_body_metadata_is_left_alone_when_forwarding_is_off(driver):
"""With the feature off the proxy does not own the field, so the pre-existing pass-through
behaviour for a caller-supplied `requestMetadata` must be unchanged."""
litellm.bedrock_request_metadata_fields = None
caller_supplied = {"user_api_key_team_alias": "caller-set", "cost_center": "cc-9"}
body = driver(litellm_params("metadata", **IDENTITY), {"requestMetadata": caller_supplied})
assert body["requestMetadata"] == caller_supplied
@pytest.mark.parametrize("driver", HEADER_DRIVERS)
def test_a_caller_header_is_left_alone_when_forwarding_is_off(driver):
"""The proxy only claims the name when the operator turned forwarding on; with the feature
off this is an ordinary passthrough header and stripping it would be a regression."""
litellm.bedrock_request_metadata_fields = None
headers = driver({BEDROCK_REQUEST_METADATA_HEADER: FORGED}, litellm_params("metadata", **IDENTITY))
assert metadata_header_values(headers) == [FORGED]