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.
This commit is contained in:
mateo-berri 2026-09-01 14:24:50 -07:00
parent 558f42e304
commit cb886a51f7
3 changed files with 90 additions and 8 deletions

View file

@ -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,

View file

@ -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

View file

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