mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
fix(bedrock): stop Converse crashing on bearer-token auth without SigV4 credentials
Since the Rust core handoff in #37241, BedrockConverseLLM.completion read access_key, secret_key and token off the boto3 credentials before asking the Rust gate whether it wanted the call. On a deployment that only sets AWS_BEARER_TOKEN_BEDROCK boto3 resolves no credentials, so every Converse call through /v1/chat/completions and /v1/responses failed with "'NoneType' object has no attribute 'access_key'", with or without the Rust opt-in Bearer auth resolves no SigV4 principal at all, and both the Python and the Rust path read the bearer token themselves, so only hand the principal keys down when boto3 actually resolved one get_request_headers now accepts credentials=None and raises botocore's NoCredentialsError when neither a bearer token nor a principal exists instead of handing SigV4Auth a None Fixes #38579 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFDpYC45u9p8eKd4aBATnS
This commit is contained in:
parent
4c3ef9ae0a
commit
f8298fa35c
4 changed files with 97 additions and 25 deletions
|
|
@ -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,
|
||||
|
|
@ -1469,9 +1469,13 @@ class BaseAWSLLM:
|
|||
try:
|
||||
from botocore.auth import SigV4Auth
|
||||
from botocore.awsrequest import AWSRequest
|
||||
from botocore.exceptions import NoCredentialsError
|
||||
except ImportError:
|
||||
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
|
||||
|
||||
if credentials is None:
|
||||
raise NoCredentialsError()
|
||||
|
||||
# Filter headers for AWS signature calculation
|
||||
# AWS SigV4 only includes specific headers in signature calculation
|
||||
aws_signature_headers: Final = self._filter_headers_for_aws_signature(headers)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -24,6 +26,22 @@ from ..common_utils import BedrockError, _get_all_bedrock_regions
|
|||
from .invoke_handler import AWSEventStreamDecoder, MockResponseIterator, make_call
|
||||
|
||||
|
||||
def _sigv4_principal(credentials: Credentials | None) -> Mapping[str, str]:
|
||||
if credentials is None:
|
||||
return MappingProxyType({})
|
||||
return MappingProxyType(
|
||||
{
|
||||
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 value is not None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_sync_call(
|
||||
client: HTTPHandler | None,
|
||||
api_base: str,
|
||||
|
|
@ -95,7 +113,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 +185,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 +349,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,19 +386,13 @@ 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 auth
|
||||
# resolves no SigV4 principal at all, and each path reads that token
|
||||
# itself.
|
||||
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),
|
||||
("aws_region_name", aws_region_name),
|
||||
)
|
||||
if value is not None
|
||||
},
|
||||
**_sigv4_principal(credentials),
|
||||
"aws_region_name": aws_region_name,
|
||||
}
|
||||
serves_via_rust: Final = rust_chat_completions_accepts(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -96,10 +96,8 @@ def _completion_kwargs(**overrides):
|
|||
return kwargs
|
||||
|
||||
|
||||
def _run(**overrides):
|
||||
with patch.object(
|
||||
BedrockConverseLLM, "get_credentials", return_value=RESOLVED_CREDENTIALS
|
||||
):
|
||||
def _run(*, credentials: Credentials | None = RESOLVED_CREDENTIALS, **overrides):
|
||||
with patch.object(BedrockConverseLLM, "get_credentials", return_value=credentials):
|
||||
return BedrockConverseLLM().completion(**_completion_kwargs(**overrides))
|
||||
|
||||
|
||||
|
|
@ -358,14 +356,27 @@ async def test_async_completion_logs_pre_call_by_default():
|
|||
assert logging_obj.pre_call.call_count == 1
|
||||
|
||||
|
||||
def _sync_client_returning_converse_response():
|
||||
def _recording_sync_client():
|
||||
"""A sync transport that answers with a Converse response and keeps every
|
||||
`post` payload, so a test can assert which headers were sent."""
|
||||
posted: list[dict] = []
|
||||
client = MagicMock()
|
||||
client.post = lambda **_kwargs: httpx.Response(
|
||||
200,
|
||||
json=CONVERSE_RESPONSE,
|
||||
request=httpx.Request("POST", "https://bedrock-runtime.us-west-2.amazonaws.com"),
|
||||
)
|
||||
|
||||
def post(**kwargs):
|
||||
posted.append(kwargs)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=CONVERSE_RESPONSE,
|
||||
request=httpx.Request("POST", "https://bedrock-runtime.us-west-2.amazonaws.com"),
|
||||
)
|
||||
|
||||
client.post = post
|
||||
client.__class__ = HTTPHandler
|
||||
return client, posted
|
||||
|
||||
|
||||
def _sync_client_returning_converse_response():
|
||||
client, _ = _recording_sync_client()
|
||||
return client
|
||||
|
||||
|
||||
|
|
@ -487,3 +498,30 @@ 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"]
|
||||
|
||||
|
||||
def test_bearer_token_auth_serves_when_boto3_resolves_no_sigv4_credentials(monkeypatch):
|
||||
"""With only `AWS_BEARER_TOKEN_BEDROCK` configured boto3 resolves no
|
||||
credentials at all. Preparing the Rust handoff must not dereference that
|
||||
None: the bearer token signs the request on its own."""
|
||||
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-bearer-token")
|
||||
client, posted = _recording_sync_client()
|
||||
|
||||
response = _run(credentials=None, litellm_params={}, client=client)
|
||||
|
||||
assert response.choices[0].message.content == "hi"
|
||||
assert posted[0]["headers"]["Authorization"] == "Bearer bedrock-bearer-token"
|
||||
|
||||
|
||||
def test_the_rust_opt_in_needs_no_sigv4_principal():
|
||||
"""The core resolves the bearer token itself, so a bearer-only deployment
|
||||
keeps its opt-in and the gate sees no aws_* credential keys to sign with."""
|
||||
seen = _inject()
|
||||
|
||||
response = _run(credentials=None, api_key="bedrock-bearer-token")
|
||||
|
||||
assert response.choices[0].message.content == "hello from rust"
|
||||
params = seen["call"][0]["optional_params"]
|
||||
assert not {"aws_access_key_id", "aws_secret_access_key", "aws_session_token"} & params.keys()
|
||||
assert params["aws_region_name"] == "us-east-1"
|
||||
assert seen["call"][0]["api_key"] == "bedrock-bearer-token"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from unittest.mock import MagicMock, patch
|
|||
from botocore.awsrequest import AWSPreparedRequest, AWSRequest
|
||||
from botocore.auth import SigV4Auth
|
||||
from botocore.credentials import Credentials
|
||||
from botocore.exceptions import NoCredentialsError
|
||||
|
||||
import litellm
|
||||
from litellm.llms.bedrock.base_aws_llm import (
|
||||
|
|
@ -801,6 +802,23 @@ def test_get_request_headers_with_sigv4():
|
|||
assert result == mock_request.prepare.return_value
|
||||
|
||||
|
||||
def test_get_request_headers_without_credentials_or_bearer_token_raises_no_credentials():
|
||||
"""Bearer-token auth needs no SigV4 principal, so `credentials` may be None.
|
||||
Reaching the SigV4 branch with neither must fail the way botocore always
|
||||
has instead of signing with a missing principal."""
|
||||
llm = BaseAWSLLM()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), pytest.raises(NoCredentialsError):
|
||||
llm.get_request_headers(
|
||||
credentials=None,
|
||||
aws_region_name="us-west-2",
|
||||
extra_headers=None,
|
||||
endpoint_url="https://api.example.com",
|
||||
data='{"prompt": "test"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def test_sigv4_matches_rust_golden_vector():
|
||||
request = AWSRequest(
|
||||
method="POST",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue