From cb886a51f709e39174e958da89f1b3e3cf512f44 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:24:50 -0700 Subject: [PATCH] fix(bedrock): keep Converse chat completions working under bearer-token auth Bearer-token Bedrock deployments resolve no SigV4 credentials, and the Converse handler read credentials.access_key while assembling the Rust hand-off params, before any request was built. Every chat completion using AWS_BEARER_TOKEN_BEDROCK or api_key crashed with an AttributeError since #37241. Hand the SigV4 keys to the Rust core only when credentials resolved; the core already reads the bearer token itself, as the Python path does. Type the credentials as optional on the Converse path and add regression tests for the Python path and the Rust hand-off. --- litellm/llms/bedrock/base_aws_llm.py | 2 +- litellm/llms/bedrock/chat/converse_handler.py | 22 ++++-- .../chat/test_bedrock_converse_handler.py | 74 +++++++++++++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index 852cfaa24f2..5ecd89fb6a0 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -1442,7 +1442,7 @@ class BaseAWSLLM: @tracer.wrap() def get_request_headers( self, - credentials: Credentials, + credentials: Credentials | None, aws_region_name: str, extra_headers: dict | None, endpoint_url: str, diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index ca5f1298360..b47dd0e53f1 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -95,7 +95,7 @@ class BedrockConverseLLM(BaseAWSLLM): stream, optional_params: dict, litellm_params: dict, - credentials: Credentials, + credentials: Credentials | None, logger_fn=None, headers={}, client: AsyncHTTPHandler | None = None, @@ -167,7 +167,7 @@ class BedrockConverseLLM(BaseAWSLLM): stream, optional_params: dict, litellm_params: dict, - credentials: Credentials, + credentials: Credentials | None, logger_fn=None, headers: dict = {}, client: AsyncHTTPHandler | None = None, @@ -331,7 +331,7 @@ class BedrockConverseLLM(BaseAWSLLM): litellm_params["aws_region_name"] = aws_region_name # [DO NOT DELETE] important for async calls - credentials: Final[Credentials] = self.get_credentials( + credentials: Final[Credentials | None] = self.get_credentials( aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, aws_session_token=aws_session_token, @@ -368,15 +368,23 @@ class BedrockConverseLLM(BaseAWSLLM): # The Rust core owns the whole call for the subset it accepts. Ask # before transforming so whichever path runs emits pre_call once, and # hand down the credentials, region and endpoint this handler already - # resolved so both paths sign as the same principal. + # resolved so both paths sign as the same principal. Bearer-token + # deployments resolve no SigV4 credentials; the core then reads the + # token from api_key or AWS_BEARER_TOKEN_BEDROCK, as the Python path does. rust_optional_params: Final = { # mutable-ok: json.dumps in the bridge rejects a mappingproxy **optional_params, **{ # mutable-ok: merged into its mutable parent above key: value for key, value in ( - ("aws_access_key_id", credentials.access_key), - ("aws_secret_access_key", credentials.secret_key), - ("aws_session_token", credentials.token), + *( + () + if credentials is None + else ( + ("aws_access_key_id", credentials.access_key), + ("aws_secret_access_key", credentials.secret_key), + ("aws_session_token", credentials.token), + ) + ), ("aws_region_name", aws_region_name), ) if value is not None 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 8e67a7e3438..0d78b5cfd8d 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 @@ -12,6 +12,7 @@ import httpx import pytest from botocore.credentials import Credentials +from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.chat.converse_handler import BedrockConverseLLM from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.rust_bridge import chat_completions as bridge @@ -487,3 +488,76 @@ def test_post_call_is_not_logged_twice_when_the_sync_rust_call_declines(): assert response.choices[0].message.content == "hi" assert len(calls["post_call"]) == 1 assert "hi" in calls["post_call"][0]["original_response"] + + +@pytest.fixture +def bearer_token_only(monkeypatch, tmp_path): + """Only a Bedrock API key is configured, so boto3 resolves no SigV4 credentials.""" + for name in ( + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_PROFILE_NAME", + "AWS_ROLE_NAME", + "AWS_ROLE_ARN", + "AWS_WEB_IDENTITY_TOKEN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(tmp_path / "no-credentials")) + monkeypatch.setenv("AWS_CONFIG_FILE", str(tmp_path / "no-config")) + monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true") + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-api-key") + BaseAWSLLM._shared_iam_cache.flush_cache() + assert BedrockConverseLLM().get_credentials(aws_region_name="us-east-1") is None + yield + BaseAWSLLM._shared_iam_cache.flush_cache() + + +def _recording_sync_client(posted: list[dict]): + client = MagicMock() + + def post(**kwargs): + posted.append(kwargs) + return httpx.Response( + 200, + json=CONVERSE_RESPONSE, + request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com"), + ) + + client.post = post + client.__class__ = HTTPHandler + return client + + +@pytest.mark.parametrize( + "api_key, expected_token", + [(None, "bedrock-api-key"), ("key-from-the-deployment", "key-from-the-deployment")], +) +def test_bearer_token_auth_without_sigv4_credentials_sends_a_bearer_request(bearer_token_only, api_key, expected_token): + """Regression: an API-key deployment resolves no SigV4 credentials, and the + handler dereferenced them while preparing the Rust hand-off, so every Converse + call crashed with an AttributeError before the request was even built.""" + posted: list[dict] = [] + response = BedrockConverseLLM().completion( + **_completion_kwargs(litellm_params={}, api_key=api_key, client=_recording_sync_client(posted)) + ) + + assert response.choices[0].message.content == "hi" + assert posted[0]["headers"]["Authorization"] == f"Bearer {expected_token}" + + +def test_bearer_token_auth_without_sigv4_credentials_hands_the_core_no_aws_keys(bearer_token_only): + """The core reads the bearer token itself, so it must get the region and none + of the SigV4 keys this handler could not resolve.""" + seen = _inject() + response = BedrockConverseLLM().completion(**_completion_kwargs()) + + assert response.choices[0].message.content == "hello from rust" + params = seen["call"][0]["optional_params"] + assert params["aws_region_name"] == "us-east-1" + assert params.keys().isdisjoint({"aws_access_key_id", "aws_secret_access_key", "aws_session_token"})