fix(bedrock_mantle): source per-request AWS credential params from litellm_params when signing chat completions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-12 00:30:19 +00:00
parent bea31871fc
commit 779441b47b
3 changed files with 120 additions and 2 deletions

View file

@ -5,7 +5,7 @@ import ssl
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping
from contextlib import asynccontextmanager
from functools import lru_cache
from types import ModuleType
from types import MappingProxyType, ModuleType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypedDict, TypeVar, Union, cast, get_type_hints
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
@ -20,6 +20,7 @@ from litellm._logging import _redact_string, verbose_logger
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.llms.base_llm.anthropic_messages.transformation import (
@ -252,6 +253,24 @@ def _has_pre_call_deployment_hook(logging_obj: LiteLLMLoggingObj) -> bool:
return False
def _aws_signing_overrides(
optional_params: Mapping[str, Any], litellm_params: Mapping[str, Any]
) -> Mapping[str, Any]:
"""AWS credential params for SigV4 signers that read them off optional_params.
Only `bedrock`/`sagemaker` keep `aws_*` in optional_params: every other provider
spreads optional_params into the request body, so the params are stripped there
and survive on litellm_params alone.
"""
return MappingProxyType(
{
key: litellm_params[key]
for key in AWS_CREDENTIAL_KWARGS_KEYS
if optional_params.get(key) is None and litellm_params.get(key) is not None
}
)
class BaseLLMHTTPHandler:
async def _make_common_async_call(
self,
@ -495,7 +514,10 @@ class BaseLLMHTTPHandler:
headers, signed_json_body = provider_config.sign_request(
headers=headers,
optional_params=optional_params,
optional_params={
**optional_params,
**_aws_signing_overrides(optional_params, litellm_params),
},
request_data=data,
api_base=api_base,
api_key=api_key,

View file

@ -489,6 +489,83 @@ class TestBedrockMantleChatAuth:
assert "/us-east-2/bedrock/aws4_request" in authorization
assert requests[0]["url"].startswith("https://bedrock-mantle.us-east-2.api.aws")
def test_completion_per_request_role_reaches_signer_and_not_the_body(
self, monkeypatch
):
# Per-request aws_role_name/aws_session_name are stripped from optional_params
# for non-bedrock providers, so they must be sourced from litellm_params at
# signing time, and must never be serialized into the provider request body.
from botocore.credentials import Credentials
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
for var in (
"BEDROCK_MANTLE_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"BEDROCK_MANTLE_API_BASE",
):
monkeypatch.delenv(var, raising=False)
credential_calls = []
def fake_get_credentials(self, **kwargs):
credential_calls.append(kwargs)
return Credentials(
access_key="ASIAEXAMPLE",
secret_key="YXNzdW1lZC1yb2xlLXNlY3JldC1hc3N1bWVk",
token="assumed-session-token",
)
monkeypatch.setattr(BaseAWSLLM, "get_credentials", fake_get_credentials)
requests = []
def mock_post(self, url, data=None, headers=None, **kwargs):
raw_body = data.decode("utf-8") if isinstance(data, bytes) else data
requests.append({"headers": headers or {}, "body": json.loads(raw_body or "{}")})
return httpx.Response(
status_code=200,
json={
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1733529600,
"model": "google.gemma-4-31b",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
},
request=httpx.Request("POST", url),
)
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post", mock_post
):
litellm.completion(
model="bedrock_mantle/google.gemma-4-31b",
messages=[{"role": "user", "content": "hello"}],
aws_role_name="arn:aws:iam::000000000000:role/attributed-role",
aws_session_name="user-123",
aws_region_name="us-east-1",
)
assert len(credential_calls) == 1
assert (
credential_calls[0]["aws_role_name"]
== "arn:aws:iam::000000000000:role/attributed-role"
)
assert credential_calls[0]["aws_session_name"] == "user-123"
assert requests[0]["headers"]["Authorization"].startswith("AWS4-HMAC-SHA256")
assert not [key for key in requests[0]["body"] if key.startswith("aws_")]
class TestBedrockMantleProjectHeader:
def test_validate_environment_sets_openai_project_header(self):

View file

@ -2071,3 +2071,22 @@ async def test_anthropic_invalid_thinking_signature_retry_resigns_bedrock_reques
retry_authorization = posts[1]["headers"]["Authorization"]
assert retry_authorization.startswith("AWS4-HMAC-SHA256")
assert retry_authorization != first_attempt_headers["Authorization"]
def test_aws_signing_overrides_only_fills_missing_credentials():
from litellm.llms.custom_httpx.llm_http_handler import _aws_signing_overrides
overrides = _aws_signing_overrides(
{"temperature": 0.2, "aws_region_name": "us-west-2"},
{
"aws_role_name": "arn:aws:iam::000000000000:role/attributed",
"aws_session_name": "user-123",
"aws_region_name": "us-east-1",
"api_key": "not-an-aws-param",
},
)
assert dict(overrides) == {
"aws_role_name": "arn:aws:iam::000000000000:role/attributed",
"aws_session_name": "user-123",
}