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"})