fix(bedrock/base_aws_llm): use lowercase content-type default to prevent SigV4 duplicate (#28491)

get_anthropic_headers() sets "content-type" (lowercase). The previous
{"Content-Type": ..., **headers} prepend left both casing variants in the
dict. botocore's AWSRequest joined the values into "application/json,
application/json" in the SigV4 canonical string while the wire request
sent only one value, causing a 401 on aws-external-anthropic.

Changing the default key to lowercase means **headers naturally overwrites
it when the caller already set "content-type", leaving exactly one entry.

Fixes: claude_platform (bedrock/claude_platform/*) always returning 401
This commit is contained in:
Connor Graham 2026-06-01 06:38:26 -04:00 • committed by GitHub
parent c87343ca5f
commit 8ed909236b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 55 additions and 3 deletions

View file

@ -1535,9 +1535,9 @@ class BaseAWSLLM:
sigv4 = SigV4Auth(credentials, service_name, aws_region_name)
if headers is not None:
headers = {"Content-Type": "application/json", **headers}
headers = {"content-type": "application/json", **headers}
else:
headers = {"Content-Type": "application/json"}
headers = {"content-type": "application/json"}
aws_signature_headers = self._filter_headers_for_aws_signature(headers)
request = AWSRequest(

View file

@ -1,8 +1,9 @@
import json
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import httpx
import pytest
from botocore.credentials import Credentials
def _anthropic_response(url: str) -> httpx.Response:
@ -310,3 +311,54 @@ async def test_anthropic_messages_routes_bedrock_claude_platform_to_messages_api
assert requests[0]["body"]["messages"] == [{"role": "user", "content": "hello"}]
assert requests[0]["body"]["max_tokens"] == 10
assert requests[0]["body"]["model"] == "claude-sonnet-4-6"
def test_sigv4_no_duplicate_content_type_when_caller_sets_lowercase():
"""
Regression: get_anthropic_headers() supplies "content-type" (lowercase).
_sign_request() used to prepend "Content-Type" (uppercase), leaving both
keys in the dict. botocore joins them into "application/json, application/json"
in the canonical string, while the wire request sends only one value → 401.
Fix: prepend with lowercase "content-type" so **headers overwrites it when
the caller already set it.
"""
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
llm = BaseAWSLLM()
mock_credentials = Credentials("key", "secret", "token")
mock_sigv4 = MagicMock()
captured: list[dict] = []
def fake_aws_request(method, url, data, headers):
captured.append(dict(headers))
req = MagicMock()
req.headers = {"Authorization": "AWS4-HMAC-SHA256 Credential=test"}
req.body = data.encode() if isinstance(data, str) else data
return req
with (
patch("botocore.auth.SigV4Auth", return_value=mock_sigv4),
patch("botocore.awsrequest.AWSRequest", side_effect=fake_aws_request),
patch.object(llm, "get_credentials", return_value=mock_credentials),
patch.object(llm, "_get_aws_region_name", return_value="us-east-1"),
):
llm._sign_request(
service_name="aws-external-anthropic",
headers={"content-type": "application/json"},
optional_params={"aws_region_name": "us-east-1"},
request_data={
"model": "claude-sonnet-4-6",
"messages": [],
"max_tokens": 10,
},
api_base="https://aws-external-anthropic.us-east-1.api.aws/v1/messages",
)
signed = captured[0]
ct_keys = [k for k in signed if k.lower() == "content-type"]
assert ct_keys == ["content-type"], (
f"Expected exactly one 'content-type' key, got {ct_keys}. "
"Duplicate keys produce 'application/json, application/json' in the "
"SigV4 canonical string and cause a 401."
)