From 808f37ad4cb2dc272d5ef74e97cf3bffe9513346 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:28:39 -0700 Subject: [PATCH 1/3] fix(bedrock): let explicit deployment credentials outrank the env bearer token AWS_BEARER_TOKEN_BEDROCK in the proxy environment used to win over aws_access_key_id, aws_secret_access_key, aws_session_token, aws_role_name, aws_profile_name, and aws_web_identity_token set on a deployment or request, so a deployment configured with its own SigV4 keys was signed with the ambient Bedrock API key instead. Bedrock API keys cannot act on S3 for the caller, so async_invoke embeddings with output_s3_uri failed with 400 "Invalid S3 credentials" even though the configured keys were valid. Explicit SigV4 params on the deployment now turn off the env bearer fallback across converse, embeddings, image generation, image edit, and Bedrock guardrails; an explicit api_key still wins over everything, api_key="" still means "sign with SigV4", and the env token still covers deployments that configure no credentials at all. --- litellm/llms/bedrock/base_aws_llm.py | 31 ++++++++++-- litellm/llms/bedrock/chat/converse_handler.py | 3 +- litellm/llms/bedrock/embed/embedding.py | 2 +- litellm/llms/bedrock/image_edit/handler.py | 2 +- .../bedrock/image_generation/image_handler.py | 2 +- .../guardrail_hooks/bedrock_guardrails.py | 10 ++-- .../chat/test_bedrock_converse_handler.py | 9 ++-- .../test_bedrock_async_invoke_embedding.py | 31 ++++++++++++ .../bedrock/embed/test_bedrock_embedding.py | 8 ++-- .../image/test_bedrock_image_bearer_token.py | 9 ++-- .../test_amazon_nova_canvas_image_edit.py | 9 ++-- .../llms/bedrock/test_base_aws_llm.py | 47 ++++++++++++++++++- .../test_bedrock_guardrails.py | 8 ++-- .../test_bedrock_invoke_guardrail_checks.py | 8 ++-- 14 files changed, 142 insertions(+), 37 deletions(-) diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index a197702921f..6b86aea51d8 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -61,9 +61,28 @@ class BearerRequestTarget(BedrockRequestTarget): credentials: None = None -def bedrock_bearer_token(api_key: str | None) -> str | None: - token: Final = api_key if api_key is not None else get_secret_str("AWS_BEARER_TOKEN_BEDROCK") - return token or None +_EXPLICIT_SIGV4_PARAMS: Final = frozenset( + ( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_role_name", + "aws_profile_name", + "aws_web_identity_token", + ) +) + + +def _has_explicit_sigv4_params(optional_params: Mapping[str, object] | None) -> bool: + return optional_params is not None and any(optional_params.get(name) for name in _EXPLICIT_SIGV4_PARAMS) + + +def bedrock_bearer_token(api_key: str | None, optional_params: Mapping[str, object] | None = None) -> str | None: + if api_key is not None: + return api_key or None + if _has_explicit_sigv4_params(optional_params): + return None + return get_secret_str("AWS_BEARER_TOKEN_BEDROCK") or None class _WebIdentityTokenClaims(BaseModel): @@ -1497,7 +1516,9 @@ class BaseAWSLLM: api_key: str | None = None, supports_bearer_token: bool = True, ) -> AWSPreparedRequest: - aws_bearer_token: Final = bedrock_bearer_token(api_key) if supports_bearer_token else None + aws_bearer_token: Final = ( + bedrock_bearer_token(api_key) if supports_bearer_token and (api_key or credentials is None) else None + ) if aws_bearer_token is not None: try: @@ -1596,7 +1617,7 @@ class BaseAWSLLM: Returns: Tuple[dict, Optional[str]]: A tuple containing the headers and the json str body of the request """ - aws_bearer_token: Final = bedrock_bearer_token(api_key) + aws_bearer_token: Final = bedrock_bearer_token(api_key, optional_params) if aws_bearer_token is not None: headers = headers or {} diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index 984ba371898..8ea8a9cec31 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -342,6 +342,7 @@ class BedrockConverseLLM(BaseAWSLLM): ) ## CREDENTIALS ## + aws_bearer_token: Final = bedrock_bearer_token(api_key, optional_params) # pop aws_secret_access_key, aws_access_key_id, aws_region_name from kwargs, since completion calls fail with them aws_secret_access_key: Final = optional_params.pop("aws_secret_access_key", None) aws_access_key_id: Final = optional_params.pop("aws_access_key_id", None) @@ -361,7 +362,7 @@ class BedrockConverseLLM(BaseAWSLLM): credentials: Final[Credentials | None] = ( None - if bedrock_bearer_token(api_key) is not None + if aws_bearer_token is not None else self.get_credentials( aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, diff --git a/litellm/llms/bedrock/embed/embedding.py b/litellm/llms/bedrock/embed/embedding.py index e249feb9ff3..60fb3e366ed 100644 --- a/litellm/llms/bedrock/embed/embedding.py +++ b/litellm/llms/bedrock/embed/embedding.py @@ -409,7 +409,7 @@ class BedrockEmbedding(BaseAWSLLM): api_key: str | None = None, ) -> EmbeddingResponse: credentials, aws_region_name = self._load_credentials( - optional_params, bearer_token=bedrock_bearer_token(api_key) + optional_params, bearer_token=bedrock_bearer_token(api_key, optional_params) ) ### TRANSFORMATION ### diff --git a/litellm/llms/bedrock/image_edit/handler.py b/litellm/llms/bedrock/image_edit/handler.py index be6489f20ae..b8874067096 100644 --- a/litellm/llms/bedrock/image_edit/handler.py +++ b/litellm/llms/bedrock/image_edit/handler.py @@ -209,7 +209,7 @@ class BedrockImageEdit(BaseAWSLLM): BedrockImageEditPreparedRequest: The prepared request object """ boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params( - optional_params, model, bearer_token=bedrock_bearer_token(api_key) + optional_params, model, bearer_token=bedrock_bearer_token(api_key, optional_params) ) # Use the existing ARN-aware provider detection method diff --git a/litellm/llms/bedrock/image_generation/image_handler.py b/litellm/llms/bedrock/image_generation/image_handler.py index 87762b648e0..4c4e3e391a8 100644 --- a/litellm/llms/bedrock/image_generation/image_handler.py +++ b/litellm/llms/bedrock/image_generation/image_handler.py @@ -231,7 +231,7 @@ class BedrockImageGeneration(BaseAWSLLM): body (bytes): The request body """ boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params( - optional_params, model, bearer_token=bedrock_bearer_token(api_key) + optional_params, model, bearer_token=bedrock_bearer_token(api_key, optional_params) ) # Use the existing ARN-aware provider detection method diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 7204839f6d3..851e54a3f8d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -784,7 +784,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): proxy_endpoint_url = f"{proxy_endpoint_url}{request_path}" encoded_data: Final = json.dumps(data).encode("utf-8") - aws_bearer_token: Final = bedrock_bearer_token(api_key) + aws_bearer_token: Final = bedrock_bearer_token(api_key, self.optional_params) if aws_bearer_token is not None: try: @@ -917,7 +917,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): source, ) return BedrockGuardrailResponse() - credentials, aws_region_name = self._load_credentials(bearer_token=bedrock_bearer_token(api_key)) + credentials, aws_region_name = self._load_credentials( + bearer_token=bedrock_bearer_token(api_key, self.optional_params) + ) allow_chunking: Final = not self._content_uses_contextual_grounding(content) completed_chunk_usages: Final[list[BedrockGuardrailUsage]] = [] # mutable-ok: billed-chunk usage accumulator @@ -1875,7 +1877,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return BedrockGuardrailResponse() api_key: Final[str | None] = request_data.get("api_key") if request_data else None - credentials, aws_region_name = self._load_credentials(bearer_token=bedrock_bearer_token(api_key)) + credentials, aws_region_name = self._load_credentials( + bearer_token=bedrock_bearer_token(api_key, self.optional_params) + ) body: Final[dict[str, object]] = {"messages": checks_messages, "checks": self.checks} prepared_request: Final = self._prepare_request( diff --git a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py index f34b8eb1fb9..6e2f0959177 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py +++ b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py @@ -520,10 +520,11 @@ def test_the_rust_opt_in_needs_no_sigv4_principal(): @pytest.mark.parametrize("configured_through", ["env_var", "api_key"]) def test_bearer_token_auth_never_runs_the_sigv4_credential_chain(monkeypatch, configured_through): - """The deployment's AWS profile does not exist, so resolving SigV4 credentials - raises; a bearer-token deployment must still serve the request, since the - bearer token alone signs it.""" + """The process's default AWS profile does not exist, so resolving SigV4 + credentials raises; a bearer-token deployment must still serve the request, + since the bearer token alone signs it.""" monkeypatch.setenv("LITELLM_RUST", "0") + monkeypatch.setenv("AWS_PROFILE", "litellm-no-such-aws-profile") if configured_through == "env_var": monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-bearer-token") else: @@ -532,7 +533,7 @@ def test_bearer_token_auth_never_runs_the_sigv4_credential_chain(monkeypatch, co response = BedrockConverseLLM().completion( **_completion_kwargs( - optional_params={"maxTokens": 16, "aws_profile_name": "litellm-no-such-aws-profile"}, + optional_params={"maxTokens": 16}, litellm_params={}, client=client, api_key="bedrock-bearer-token" if configured_through == "api_key" else None, diff --git a/tests/test_litellm/llms/bedrock/embed/test_bedrock_async_invoke_embedding.py b/tests/test_litellm/llms/bedrock/embed/test_bedrock_async_invoke_embedding.py index ddbd3a2e9ba..446319a1b4e 100644 --- a/tests/test_litellm/llms/bedrock/embed/test_bedrock_async_invoke_embedding.py +++ b/tests/test_litellm/llms/bedrock/embed/test_bedrock_async_invoke_embedding.py @@ -184,6 +184,37 @@ class TestBedrockAsyncInvokeEmbedding: request_url = mock_post.call_args.kwargs.get("url", "") assert "/async-invoke" in request_url + def test_async_invoke_signs_with_explicit_deployment_keys_over_env_bearer_token(self, monkeypatch): + """A deployment configured with its own IAM keys must sign StartAsyncInvoke + with SigV4 even when AWS_BEARER_TOKEN_BEDROCK is set in the process: a + Bedrock API key cannot write the S3 output, so Bedrock rejects the bearer + request with "Invalid S3 credentials".""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + client = HTTPHandler() + + with patch.object(client, "post") as mock_post: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = json.dumps(async_invoke_response) + mock_response.json = lambda: json.loads(mock_response.text) + mock_post.return_value = mock_response + + response = litellm.embedding( + model="bedrock/async_invoke/twelvelabs.marengo-embed-3-0-v1:0", + input=test_input, + client=client, + aws_region_name="us-east-1", + aws_access_key_id="AKIAEXPLICITDEPLOYMENT", + aws_secret_access_key="explicit-deployment-secret", + input_type="text", + output_s3_uri="s3://test-bucket/async-invoke-output/", + ) + + authorization = mock_post.call_args.kwargs["headers"]["Authorization"] + assert response._hidden_params._invocation_arn == async_invoke_response["invocationArn"] + assert authorization.startswith("AWS4-HMAC-SHA256 Credential=AKIAEXPLICITDEPLOYMENT/") + assert "Bearer" not in authorization + def test_async_invoke_marengo_3_wraps_the_nested_payload_with_the_base_model_id(self): client = HTTPHandler() diff --git a/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py b/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py index bcd1a29d0e8..b29d1b886cc 100644 --- a/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py +++ b/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py @@ -1037,10 +1037,11 @@ def test_load_credentials_assumes_role_with_external_id(monkeypatch): def test_bedrock_embedding_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): - """The deployment's AWS profile does not exist, so resolving SigV4 credentials - raises; a bearer-token deployment must still serve the request, since the - bearer token alone signs it.""" + """The process's default AWS profile does not exist, so resolving SigV4 + credentials raises; a bearer-token deployment must still serve the request, + since the bearer token alone signs it.""" monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + monkeypatch.setenv("AWS_PROFILE", "litellm-no-such-aws-profile") client = HTTPHandler() with patch.object(client, "post") as mock_post: @@ -1055,7 +1056,6 @@ def test_bedrock_embedding_bearer_token_never_runs_the_sigv4_credential_chain(mo input=test_input, client=client, aws_region_name="us-west-2", - aws_profile_name="litellm-no-such-aws-profile", ) assert response.data[0]["embedding"] == titan_embedding_response["embedding"] diff --git a/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py b/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py index 0b11a66c100..4c737945349 100644 --- a/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py +++ b/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py @@ -138,17 +138,18 @@ class TestBedrockImageGeneration: def test_image_generation_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): - """The deployment's AWS profile does not exist, so resolving SigV4 credentials - raises; a bearer-token deployment must still sign the request with the - bearer token alone.""" + """The process's default AWS profile does not exist, so resolving SigV4 + credentials raises; a bearer-token deployment must still sign the request + with the bearer token alone.""" from litellm.llms.bedrock.image_generation.image_handler import BedrockImageGeneration monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + monkeypatch.setenv("AWS_PROFILE", "litellm-no-such-aws-profile") request = BedrockImageGeneration()._prepare_request( model="amazon.nova-canvas-v1:0", prompt="A cute baby sea otter", - optional_params={"aws_region_name": "us-west-2", "aws_profile_name": "litellm-no-such-aws-profile"}, + optional_params={"aws_region_name": "us-west-2"}, api_base=None, extra_headers=None, api_key=None, diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 58411a9ae18..5046c81d9b2 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -659,16 +659,17 @@ def test_transform_response_empty_images_without_error_raises(): def test_prepare_request_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): - """The deployment's AWS profile does not exist, so resolving SigV4 credentials - raises; a bearer-token deployment must still sign the request with the - bearer token alone.""" + """The process's default AWS profile does not exist, so resolving SigV4 + credentials raises; a bearer-token deployment must still sign the request + with the bearer token alone.""" monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + monkeypatch.setenv("AWS_PROFILE", "litellm-no-such-aws-profile") request = BedrockImageEdit()._prepare_request( model="amazon.nova-canvas-v1:0", image=[io.BytesIO(b"fake-png")], prompt="make it warmer", - optional_params={"aws_region_name": "us-west-2", "aws_profile_name": "litellm-no-such-aws-profile"}, + optional_params={"aws_region_name": "us-west-2"}, api_base=None, extra_headers=None, logging_obj=Mock(), diff --git a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py index a1c28f36e70..3790e7210b9 100644 --- a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py +++ b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py @@ -743,7 +743,7 @@ def test_sign_request_with_api_key_bearer_token(): def test_get_request_headers_with_env_var_bearer_token(): # Setup llm = BaseAWSLLM() - credentials = Credentials("test_key", "test_secret", "test_token") + credentials = None headers = {"Content-Type": "application/json"} headers_dict = headers.copy() @@ -776,6 +776,51 @@ def test_get_request_headers_with_env_var_bearer_token(): assert result == mock_prepared_request +@pytest.mark.parametrize( + ("api_key", "optional_params", "expected"), + [ + (None, {}, "env-bearer-token-12345"), + (None, {"aws_region_name": "us-west-2", "aws_access_key_id": None}, "env-bearer-token-12345"), + (None, {"aws_access_key_id": "AKIAEXPLICIT", "aws_secret_access_key": "secret"}, None), + (None, {"aws_session_token": "session"}, None), + (None, {"aws_profile_name": "deployment-profile"}, None), + (None, {"aws_role_name": "arn:aws:iam::123456789012:role/deployment"}, None), + (None, {"aws_web_identity_token": "oidc-token"}, None), + ("explicit-bearer", {"aws_access_key_id": "AKIAEXPLICIT"}, "explicit-bearer"), + ("", {}, None), + ("", {"aws_access_key_id": "AKIAEXPLICIT"}, None), + ], +) +def test_bedrock_bearer_token_env_token_yields_to_explicit_sigv4_params(monkeypatch, api_key, optional_params, expected): + """The env bearer token is the ambient fallback: explicit SigV4 params on the + deployment turn it off, an explicit api_key still wins over both, and an + explicit empty api_key keeps meaning "no bearer, sign with SigV4".""" + from litellm.llms.bedrock.base_aws_llm import bedrock_bearer_token + + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + + assert bedrock_bearer_token(api_key, optional_params) == expected + + +def test_get_request_headers_signs_with_resolved_credentials_despite_env_bearer_token(monkeypatch): + """Credentials resolved from the deployment's explicit SigV4 params must sign + the request even when the process carries AWS_BEARER_TOKEN_BEDROCK, which a + Bedrock API key cannot do on the caller's behalf for S3-backed calls.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + + prepared = BaseAWSLLM().get_request_headers( + credentials=Credentials("test_key", "test_secret", "test_token"), + aws_region_name="us-west-2", + extra_headers=None, + endpoint_url="https://bedrock-runtime.us-west-2.amazonaws.com/async-invoke", + data='{"prompt": "test"}', + headers={"Content-Type": "application/json"}, + ) + + assert prepared.headers["Authorization"].startswith("AWS4-HMAC-SHA256 Credential=test_key/") + assert "Bearer" not in prepared.headers["Authorization"] + + def test_get_request_headers_with_sigv4(): # Setup llm = BaseAWSLLM() diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 7da55f22bda..89466bccb13 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -5824,14 +5824,14 @@ async def test_apply_guardrail_debug_log_masks_signed_request_headers(): @pytest.mark.asyncio async def test_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): - """The guardrail's AWS profile does not exist, so resolving SigV4 credentials - raises; with a bearer token configured the guardrail must still run, since - the bearer token alone signs the request.""" + """The process's default AWS profile does not exist, so resolving SigV4 + credentials raises; with a bearer token configured the guardrail must still + run, since the bearer token alone signs the request.""" monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + monkeypatch.setenv("AWS_PROFILE", "litellm-no-such-aws-profile") guardrail = BedrockGuardrail( guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT", - aws_profile_name="litellm-no-such-aws-profile", ) mock_response = MagicMock() mock_response.status_code = 200 diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_invoke_guardrail_checks.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_invoke_guardrail_checks.py index f4af77d2e40..cf67268b393 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_invoke_guardrail_checks.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_invoke_guardrail_checks.py @@ -837,14 +837,14 @@ async def test_many_blocks_scanned_at_request_level_and_can_block(): @pytest.mark.asyncio async def test_checks_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): - """Same bearer-token rule as ApplyGuardrail: the guardrail's AWS profile does - not exist, yet the InvokeGuardrailChecks call still goes out on the bearer - token and its verdict is enforced.""" + """Same bearer-token rule as ApplyGuardrail: the process's default AWS + profile does not exist, yet the InvokeGuardrailChecks call still goes out on + the bearer token and its verdict is enforced.""" monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + monkeypatch.setenv("AWS_PROFILE", "litellm-no-such-aws-profile") g = BedrockGuardrail( checks=CONTENT_FILTER_CHECKS, content_filter_threshold=0.5, - aws_profile_name="litellm-no-such-aws-profile", ) payload = {"results": {"contentFilter": {"results": [{"category": "VIOLENCE", "severityScore": 0.8}]}}} post = AsyncMock(return_value=_mock_http_response(200, payload)) From 203e44e7a11a30570429d7c34f81ded65974306b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:12:48 -0700 Subject: [PATCH 2/3] fix(bedrock): keep explicit SigV4 keys ahead of the env bearer token on the Rust converse handoff --- litellm/llms/bedrock/chat/converse_handler.py | 14 +++-- .../chat/test_bedrock_converse_handler.py | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index 8ea8a9cec31..a83c63f444a 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -26,6 +26,12 @@ from ..common_utils import BedrockError, _get_all_bedrock_regions, error_respons from .invoke_handler import AWSEventStreamDecoder, MockResponseIterator, make_call +def _rust_bearer_api_key(api_key: str | None, credentials: Credentials | None) -> str | None: + if credentials is None: + return api_key + return "" + + def _sigv4_principal(credentials: Credentials | None) -> Mapping[str, str]: if credentials is None: return MappingProxyType({}) @@ -403,12 +409,14 @@ class BedrockConverseLLM(BaseAWSLLM): # hand down the credentials, region and endpoint this handler already # resolved so both paths sign as the same principal. Bearer-token auth # resolves no SigV4 principal at all, and each path reads that token - # itself. + # itself; a resolved principal travels with an empty api_key so the + # core signs with it instead of reaching for the env bearer token. rust_optional_params: Final = { # mutable-ok: json.dumps in the bridge rejects a mappingproxy **optional_params, **_sigv4_principal(credentials), "aws_region_name": aws_region_name, } + rust_api_key: Final = _rust_bearer_api_key(api_key, credentials) serves_via_rust: Final = rust_chat_completions_accepts( model=model, messages=messages, @@ -439,7 +447,7 @@ class BedrockConverseLLM(BaseAWSLLM): messages=messages, optional_params=rust_optional_params, model_response=model_response, - api_key=api_key, + api_key=rust_api_key, api_base=proxy_endpoint_url, custom_llm_provider="bedrock", extra_headers=headers, @@ -469,7 +477,7 @@ class BedrockConverseLLM(BaseAWSLLM): messages=messages, optional_params=rust_optional_params, model_response=model_response, - api_key=api_key, + api_key=rust_api_key, api_base=proxy_endpoint_url, custom_llm_provider="bedrock", extra_headers=headers, diff --git a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py index 6e2f0959177..af987b645c5 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py +++ b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py @@ -518,6 +518,58 @@ def test_the_rust_opt_in_needs_no_sigv4_principal(): assert seen["call"][0]["api_key"] == "bedrock-bearer-token" +def test_the_rust_path_signs_with_explicit_deployment_keys_over_the_env_bearer_token(monkeypatch): + """A deployment configured with its own SigV4 keys must keep signing as + that principal when the process also carries `AWS_BEARER_TOKEN_BEDROCK`. + The core reads the env token whenever the host passes no api_key, so the + host hands down an empty one alongside the resolved principal.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-bearer-token") + seen = _inject() + + response = BedrockConverseLLM().completion( + **_completion_kwargs( + optional_params={ + "maxTokens": 16, + "aws_access_key_id": "AKIAEXPLICITDEPLOYMENT", + "aws_secret_access_key": "explicit-secret", + } + ) + ) + + assert response.choices[0].message.content == "hello from rust" + assert seen["call"][0]["api_key"] == "" + params = seen["call"][0]["optional_params"] + assert params["aws_access_key_id"] == "AKIAEXPLICITDEPLOYMENT" + assert params["aws_secret_access_key"] == "explicit-secret" + + +@pytest.mark.asyncio +async def test_the_async_rust_path_signs_with_explicit_deployment_keys_over_the_env_bearer_token(monkeypatch): + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-bearer-token") + seen: dict[str, list[dict]] = {"call": []} + + async def native(**kwargs): + seen["call"].append(kwargs) + return dict(RUST_RESPONSE) + + bridge.set_rust_chat_completions(decline=lambda **_kwargs: None, achat_completions=native) + + result = await BedrockConverseLLM().completion( + **_completion_kwargs( + acompletion=True, + optional_params={ + "maxTokens": 16, + "aws_access_key_id": "AKIAEXPLICITDEPLOYMENT", + "aws_secret_access_key": "explicit-secret", + }, + ) + ) + + assert result.choices[0].message.content == "hello from rust" + assert seen["call"][0]["api_key"] == "" + assert seen["call"][0]["optional_params"]["aws_access_key_id"] == "AKIAEXPLICITDEPLOYMENT" + + @pytest.mark.parametrize("configured_through", ["env_var", "api_key"]) def test_bearer_token_auth_never_runs_the_sigv4_credential_chain(monkeypatch, configured_through): """The process's default AWS profile does not exist, so resolving SigV4 From 33196c985b8bd1b5babfc7b746f4bcad630678a2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:49:26 -0800 Subject: [PATCH 3/3] fix(bedrock): let explicit AWS keys win over the env bearer token on native Chat Completions --- .../chat/chat_completions/transformation.py | 3 +- ...bedrock_chat_completions_transformation.py | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/litellm/llms/bedrock/chat/chat_completions/transformation.py b/litellm/llms/bedrock/chat/chat_completions/transformation.py index dd2277a6c35..0375a5746b1 100644 --- a/litellm/llms/bedrock/chat/chat_completions/transformation.py +++ b/litellm/llms/bedrock/chat/chat_completions/transformation.py @@ -47,6 +47,7 @@ from litellm.llms.bedrock.common_utils import ( ) from litellm.llms.openai.chat.gpt_transformation import OpenAIChatCompletionStreamingHandler from litellm.llms.openai_like.chat.transformation import OpenAILikeChatConfig +from litellm.types.llms.bedrock import AwsAuthParams from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import Choices, ModelResponse, ModelResponseStream @@ -355,7 +356,7 @@ class AmazonBedrockRuntimeChatCompletionsConfig(OpenAILikeChatConfig): messages=messages, optional_params=optional_params, litellm_params=litellm_params, - api_key=bedrock_bearer_token(api_key), + api_key=bedrock_bearer_token(api_key, AwsAuthParams.model_validate(optional_params)), api_base=api_base, ) diff --git a/tests/unit/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py b/tests/unit/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py index f4dcff1969e..b35405d3536 100644 --- a/tests/unit/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py +++ b/tests/unit/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py @@ -550,6 +550,36 @@ def test_bearer_api_key_is_sent_as_the_authorization_header(monkeypatch): assert headers["Authorization"] == "Bearer bedrock-api-key" +@pytest.mark.parametrize( + "auth_kwargs, authorization_prefix", + [ + ( + { + "aws_access_key_id": SIGV4_PARAMS["aws_access_key_id"], + "aws_secret_access_key": SIGV4_PARAMS["aws_secret_access_key"], + }, + f"AWS4-HMAC-SHA256 Credential={SIGV4_PARAMS['aws_access_key_id']}/", + ), + ({}, "Bearer env-bedrock-token"), + ], + ids=["explicit-keys-sign-sigv4", "no-keys-use-env-token"], +) +def test_explicit_aws_keys_win_over_the_env_bearer_token( + local_cost_map, fake_aws_env, monkeypatch, auth_kwargs, authorization_prefix +): + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bedrock-token") + requests, client = _recording_client(json=_chat_completion_json("ok", "us.xai.grok-4.6")) + litellm.completion( + model="bedrock/chat_completions/us.xai.grok-4.6", + messages=[{"role": "user", "content": "hello"}], + client=client, + **auth_kwargs, + ) + + assert len(requests) == 1 + assert requests[0].headers["Authorization"].startswith(authorization_prefix), requests[0].headers + + @pytest.mark.parametrize( "request_params, expected_route", [