mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
* Fix Bedrock KB pass-through SigV4 headers and signed body Coerce botocore HeadersDict to a dict for pass-through routes. When forward_headers is true, drop request headers that collide case-insensitively with signed headers so client Bearer auth does not shadow AWS SigV4. Send prepped.body as raw content so the outbound payload matches the signature after logging hooks mutate the parsed dict. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify pass-through raw body handling Read the SigV4-signed bytes directly from request.state inside pass_through_request instead of threading a custom_raw_body argument through three functions. Helper methods are restored to their original signatures, and the new branch lives in one place at each httpx call site. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden pass-through raw body read from request.state Guard missing request.state (test fixtures) and ignore non-bytes/str values so MagicMock does not trigger the SigV4 raw-body path. Co-authored-by: Cursor <cursoragent@cursor.com> * Test pass_through_request state_raw_body uses httpx content= Cover non-streaming (async_client.request) and streaming (build_request) paths so SigV4 bytes on request.state are not replaced by json= of a hook-mutated dict. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
from typing import Dict, List, Mapping, Optional, Union
|
|
from urllib.parse import parse_qs
|
|
|
|
import httpx
|
|
|
|
from litellm._logging import verbose_logger
|
|
from litellm.constants import PASS_THROUGH_HEADER_PREFIX
|
|
|
|
# Headers that must not be overwritten via the x-pass- forwarding mechanism.
|
|
# Includes standard credential/auth headers and protocol-level headers that
|
|
# affect routing or message framing.
|
|
_PASS_THROUGH_PROTECTED_HEADERS: frozenset = frozenset(
|
|
{
|
|
"authorization",
|
|
"api-key",
|
|
"x-api-key",
|
|
"x-goog-api-key",
|
|
"host",
|
|
"content-length",
|
|
}
|
|
)
|
|
|
|
# Header name prefix used to block AWS SigV4 signing headers from being overridden.
|
|
_PASS_THROUGH_PROTECTED_HEADER_PREFIXES: tuple = ("x-amz-",)
|
|
|
|
|
|
class BasePassthroughUtils:
|
|
@staticmethod
|
|
def get_merged_query_parameters(
|
|
existing_url: httpx.URL,
|
|
request_query_params: Mapping[str, Union[str, list]],
|
|
default_query_params: Optional[Dict[str, Union[str, list]]] = None,
|
|
) -> Dict[str, Union[str, List[str]]]:
|
|
# Get the existing query params from the target URL
|
|
existing_query_string = existing_url.query.decode("utf-8")
|
|
existing_query_params = parse_qs(existing_query_string)
|
|
|
|
# parse_qs returns a dict where each value is a list, so let's flatten it
|
|
updated_existing_query_params = {
|
|
k: v[0] if len(v) == 1 else v for k, v in existing_query_params.items()
|
|
}
|
|
|
|
# Start with default query params (lowest priority)
|
|
merged_params = {}
|
|
if default_query_params:
|
|
merged_params.update(default_query_params)
|
|
|
|
# Override with existing URL query params (medium priority)
|
|
merged_params.update(updated_existing_query_params)
|
|
|
|
# Override with request query params (highest priority - client can override anything)
|
|
merged_params.update(request_query_params)
|
|
|
|
return merged_params
|
|
|
|
@staticmethod
|
|
def forward_headers_from_request(
|
|
request_headers: dict,
|
|
headers: dict,
|
|
forward_headers: Optional[bool] = False,
|
|
):
|
|
"""
|
|
Helper to forward headers from original request.
|
|
|
|
Also handles 'x-pass-' prefixed headers which are always forwarded
|
|
with the prefix stripped, regardless of forward_headers setting.
|
|
e.g., 'x-pass-anthropic-beta: value' becomes 'anthropic-beta: value'
|
|
"""
|
|
if forward_headers is True:
|
|
# Header We Should NOT forward
|
|
request_headers.pop("content-length", None)
|
|
request_headers.pop("host", None)
|
|
|
|
custom_header_names = {header_name.lower() for header_name in headers}
|
|
for header_name in list(request_headers.keys()):
|
|
if header_name.lower() in custom_header_names:
|
|
request_headers.pop(header_name, None)
|
|
|
|
# Combine request headers with custom headers
|
|
headers = {**request_headers, **headers}
|
|
|
|
# Process x-pass- prefixed headers (strip prefix and forward)
|
|
# Credential and protocol-level headers are excluded from this mechanism.
|
|
for header_name, header_value in request_headers.items():
|
|
if header_name.lower().startswith(PASS_THROUGH_HEADER_PREFIX):
|
|
# Strip the 'x-pass-' prefix and normalize to lowercase
|
|
actual_header_name = header_name[
|
|
len(PASS_THROUGH_HEADER_PREFIX) :
|
|
].lower()
|
|
if actual_header_name in _PASS_THROUGH_PROTECTED_HEADERS or any(
|
|
actual_header_name.startswith(p)
|
|
for p in _PASS_THROUGH_PROTECTED_HEADER_PREFIXES
|
|
):
|
|
verbose_logger.debug(
|
|
"x-pass- header %s maps to a protected header name; skipping",
|
|
header_name,
|
|
)
|
|
continue
|
|
headers[actual_header_name] = header_value
|
|
|
|
return headers
|
|
|
|
|
|
class CommonUtils:
|
|
@staticmethod
|
|
def encode_bedrock_runtime_modelid_arn(endpoint: str) -> str:
|
|
"""
|
|
Encodes any "/" found in the modelId of an AWS Bedrock Runtime Endpoint when arns are passed in.
|
|
- modelID value can be an ARN which contains slashes that SHOULD NOT be treated as path separators.
|
|
e.g endpoint: /model/<modelId>/invoke
|
|
<modelId> containing arns with slashes need to be encoded from
|
|
arn:aws:bedrock:ap-southeast-1:123456789012:application-inference-profile/abdefg12334 =>
|
|
arn:aws:bedrock:ap-southeast-1:123456789012:application-inference-profile%2Fabdefg12334
|
|
so that it is treated as one part of the path.
|
|
Otherwise, the encoded endpoint will return 500 error when passed to Bedrock endpoint.
|
|
|
|
See the apis in https://docs.aws.amazon.com/bedrock/latest/APIReference/API_Operations_Amazon_Bedrock_Runtime.html
|
|
for more details on the regex patterns of modelId which we use in the regex logic below.
|
|
|
|
Args:
|
|
endpoint (str): The original endpoint string which may contain ARNs that contain slashes.
|
|
|
|
Returns:
|
|
str: The endpoint with properly encoded ARN slashes
|
|
"""
|
|
import re
|
|
|
|
# Early exit: if no ARN detected, return unchanged
|
|
if "arn:aws:" not in endpoint:
|
|
return endpoint
|
|
|
|
# Handle all patterns in one go - more efficient and cleaner
|
|
patterns = [
|
|
# Custom model with 2 slashes (order matters - do this first)
|
|
(r"(custom-model)/([a-z0-9.-]+)/([a-z0-9]+)", r"\1%2F\2%2F\3"),
|
|
# All other resource types with 1 slash
|
|
(r"(:application-inference-profile)/", r"\1%2F"),
|
|
(r"(:inference-profile)/", r"\1%2F"),
|
|
(r"(:foundation-model)/", r"\1%2F"),
|
|
(r"(:imported-model)/", r"\1%2F"),
|
|
(r"(:provisioned-model)/", r"\1%2F"),
|
|
(r"(:prompt)/", r"\1%2F"),
|
|
(r"(:endpoint)/", r"\1%2F"),
|
|
(r"(:prompt-router)/", r"\1%2F"),
|
|
(r"(:default-prompt-router)/", r"\1%2F"),
|
|
]
|
|
|
|
for pattern, replacement in patterns:
|
|
# Check if pattern exists before applying regex (early exit optimization)
|
|
if re.search(pattern, endpoint):
|
|
endpoint = re.sub(pattern, replacement, endpoint)
|
|
break # Exit after first match since each ARN has only one resource type
|
|
|
|
return endpoint
|