fix(bedrock): null value handling in PII guardrails + strip unsupported params

- Fix null value handling in PII redaction guardrails
- Strip unsupported body params (context_management, cache_control scope)
  and invalid model ID suffix for Bedrock
This commit is contained in:
Quentin Machu 2026-05-03 23:09:50 -04:00 • committed by Quentin Machu
parent 28c0d8579b
commit e1462c9b17
No known key found for this signature in database
8 changed files with 319 additions and 47 deletions

View file

@ -1149,7 +1149,6 @@ BEDROCK_CONVERSE_MODELS = [
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-opus-4-8",
"anthropic.claude-opus-4-7",
"anthropic.claude-opus-4-6-v1:0",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-opus-4-1-20250805-v1:0",

View file

@ -0,0 +1,67 @@
# AWS Bedrock Provider
This directory contains the AWS Bedrock provider implementation for LiteLLM.
## Beta Headers Management
### Overview
Bedrock anthropic-beta header handling uses a centralized whitelist-based filter (`beta_headers_config.py`) across all three Bedrock APIs to ensure:
- Only supported headers reach AWS (prevents API errors)
- Consistent behavior across Invoke Chat, Invoke Messages, and Converse APIs
- Zero maintenance when new Claude models are released
### Key Features
1. **Version-Based Filtering**: Headers specify minimum version (e.g., "requires Claude 4.5+") instead of hardcoded model lists
2. **Family Restrictions**: Can limit headers to specific families (opus/sonnet/haiku)
3. **Automatic Translation**: `advanced-tool-use` → `tool-search-tool` + `tool-examples` for backward compatibility
### Adding New Beta Headers
When AWS Bedrock adds support for a new Anthropic beta header, update `beta_headers_config.py`:
```python
# 1. Add to whitelist
BEDROCK_CORE_SUPPORTED_BETAS.add("new-feature-2027-01-15")
# 2. (Optional) Add version requirement
BETA_HEADER_MINIMUM_VERSION["new-feature-2027-01-15"] = 5.0
# 3. (Optional) Add family restriction
BETA_HEADER_FAMILY_RESTRICTIONS["new-feature-2027-01-15"] = ["opus"]
```
Then add tests in `tests/test_litellm/llms/bedrock/test_beta_headers_config.py`.
### Adding New Claude Models
When Anthropic releases new models (e.g., Claude Opus 5):
- **Required code changes**: ZERO ✅
- The version-based filter automatically handles new models
- No hardcoded lists to update
### Testing
```bash
# Test beta headers filtering
poetry run pytest tests/test_litellm/llms/bedrock/test_beta_headers_config.py -v
# Test API integrations
poetry run pytest tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py -v
# Test everything
poetry run pytest tests/test_litellm/llms/bedrock/ -v
```
### Debug Logging
Enable debug logging to see filtering decisions:
```bash
LITELLM_LOG=DEBUG
```
### References
- [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html)
- [Anthropic Beta Headers](https://docs.anthropic.com/claude/reference/versioning)

View file

@ -32,6 +32,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_tools_pt,
make_valid_bedrock_tool_name,
)
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
from litellm.llms.anthropic.chat.transformation import (
DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING,
REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT,
@ -90,13 +91,6 @@ BEDROCK_COMPUTER_USE_TOOLS = [
"text_editor_",
]
# Beta header patterns that are not supported by Bedrock Converse API
# These will be filtered out to prevent errors
UNSUPPORTED_BEDROCK_CONVERSE_BETA_PATTERNS = [
"advanced-tool-use", # Bedrock Converse doesn't support advanced-tool-use beta headers
"prompt-caching", # Prompt caching not supported in Converse API
"compact-2026-01-12", # The compact beta feature is not currently supported on the Converse and ConverseStream APIs
]
class AmazonConverseConfig(BaseConfig):
@ -1345,6 +1339,10 @@ class AmazonConverseConfig(BaseConfig):
# These are LiteLLM internal parameters, not API parameters
additional_request_params = filter_internal_params(additional_request_params)
# Remove Anthropic-specific body params that Bedrock doesn't support
# (these features are enabled via anthropic-beta headers instead)
additional_request_params.pop("context_management", None)
# Filter out non-serializable objects (exceptions, callables, logging objects, etc.)
# from additional_request_params to prevent JSON serialization errors
# This filters: Exception objects, callable objects (functions), Logging objects, etc.
@ -1517,15 +1515,16 @@ class AmazonConverseConfig(BaseConfig):
if ANTHROPIC_EFFORT_BETA_HEADER not in anthropic_beta_list:
anthropic_beta_list.append(ANTHROPIC_EFFORT_BETA_HEADER)
# Bedrock Converse: compact_20260112 edits only (+ beta header).
AmazonConverseConfig._filter_context_management_for_bedrock_converse(
additional_request_params, anthropic_beta_list
)
# Set anthropic_beta in additional_request_params if we have any beta features
# ONLY apply to Anthropic/Claude models - other models (e.g., Qwen, Llama) don't support this field
if anthropic_beta_list and base_model.startswith("anthropic"):
additional_request_params["anthropic_beta"] = anthropic_beta_list
filtered_betas = filter_and_transform_beta_headers(
beta_headers=anthropic_beta_list,
provider="bedrock",
)
if filtered_betas:
additional_request_params["anthropic_beta"] = filtered_betas
return bedrock_tools, anthropic_beta_list

View file

@ -212,6 +212,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
anthropic_request.pop("model", None)
anthropic_request.pop("stream", None)
anthropic_request.pop("context_management", None)
output_format = anthropic_request.pop("output_format", None)
output_config_format = pop_bedrock_invoke_output_config_format(
anthropic_request
@ -247,11 +248,41 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
if "anthropic_version" not in anthropic_request:
anthropic_request["anthropic_version"] = self.anthropic_version
# Strip unsupported cache_control fields (e.g. scope) — Bedrock rejects them
# with "Extra inputs are not permitted"
self._strip_unsupported_cache_control_fields(anthropic_request)
# Remove `custom` field from tools (Bedrock doesn't support it)
remove_custom_field_from_tools(anthropic_request)
normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_request)
return anthropic_request
@staticmethod
def _strip_unsupported_cache_control_fields(request: dict) -> None:
"""Strip fields from cache_control that Bedrock doesn't support (e.g. scope)."""
def _sanitize(cache_control: dict) -> None:
if isinstance(cache_control, dict):
cache_control.pop("scope", None)
def _process_content(content: list) -> None:
for item in content:
if isinstance(item, dict) and "cache_control" in item:
_sanitize(item["cache_control"])
for key in ("system", "messages"):
items = request.get(key)
if not items or not isinstance(items, list):
continue
if key == "system":
_process_content(items)
else:
for msg in items:
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, list):
_process_content(content)
def _compute_bedrock_invoke_beta_headers(
self,
model: str,
@ -280,8 +311,9 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
programmatic_tool_calling_used or input_examples_used
):
beta_set.discard(ANTHROPIC_TOOL_SEARCH_BETA_HEADER)
if "opus-4" in model.lower() or "opus_4" in model.lower():
beta_set.add("tool-search-tool-2025-10-19")
beta_set.add(
"tool-search-tool-2025-10-19"
) # centralized filter handles model restriction
auto_beta_list = filter_and_transform_beta_headers(
beta_headers=list(beta_set - user_beta_set),

View file

@ -412,8 +412,9 @@ class AmazonAnthropicClaudeMessagesConfig(
programmatic_tool_calling_used or input_examples_used
):
beta_set.discard(ANTHROPIC_TOOL_SEARCH_BETA_HEADER)
if self._supports_tool_search_on_bedrock(model):
beta_set.add("tool-search-tool-2025-10-19")
# Both headers must be sent together; centralized filter handles model restriction
beta_set.add("tool-search-tool-2025-10-19")
beta_set.add("tool-examples-2025-10-29")
@staticmethod
def _filter_context_management_for_bedrock_invoke(
@ -504,9 +505,6 @@ class AmazonAnthropicClaudeMessagesConfig(
beta_set=beta_set,
)
if "tool-search-tool-2025-10-19" in beta_set:
beta_set.add("tool-examples-2025-10-29")
filtered_betas = sorted(
filter_and_transform_beta_headers(
beta_headers=list(beta_set),

View file

@ -920,15 +920,13 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
sensitive_info_policy = assessment.get("sensitiveInformationPolicy")
if sensitive_info_policy:
pii_entities = sensitive_info_policy.get("piiEntities") or []
if pii_entities:
for pii_entity in pii_entities:
if pii_entity.get("action") == "BLOCKED":
return True
for pii_entity in pii_entities:
if pii_entity.get("action") == "BLOCKED":
return True
regexes = sensitive_info_policy.get("regexes") or []
if regexes:
for regex in regexes:
if regex.get("action") == "BLOCKED":
return True
for regex in regexes:
if regex.get("action") == "BLOCKED":
return True
# Check contextual grounding policy
contextual_grounding_policy = assessment.get("contextualGroundingPolicy")

View file

@ -52,10 +52,10 @@ class TestAnthropicBetaHeaderSupport:
def test_invoke_transformation_anthropic_beta(self):
"""Test that Invoke API transformation includes anthropic_beta in request."""
config = AmazonAnthropicClaudeConfig()
headers = {"anthropic-beta": "context-1m-2025-08-07,computer-use-2024-10-22"}
headers = {"anthropic-beta": "context-1m-2025-08-07,computer-use-2025-01-24"}
result = config.transform_request(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
model="anthropic.claude-opus-4-5-20250514-v1:0",
messages=[{"role": "user", "content": "Test"}],
optional_params={},
litellm_params={},
@ -66,18 +66,16 @@ class TestAnthropicBetaHeaderSupport:
# Beta flags are stored as sets, so order may vary
assert set(result["anthropic_beta"]) == {
"context-1m-2025-08-07",
"computer-use-2024-10-22",
"computer-use-2025-01-24",
}
def test_converse_transformation_anthropic_beta(self):
"""Test that Converse API transformation includes anthropic_beta in additionalModelRequestFields."""
config = AmazonConverseConfig()
headers = {
"anthropic-beta": "context-1m-2025-08-07,interleaved-thinking-2025-05-14"
}
headers = {"anthropic-beta": "context-1m-2025-08-07,computer-use-2025-01-24"}
result = config._transform_request_helper(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
model="anthropic.claude-opus-4-5-20250514-v1:0",
system_content_blocks=[],
optional_params={},
messages=[{"role": "user", "content": "Test"}],
@ -89,7 +87,7 @@ class TestAnthropicBetaHeaderSupport:
assert "anthropic_beta" in additional_fields
# Sort both arrays before comparing to avoid flakiness from ordering differences
assert sorted(additional_fields["anthropic_beta"]) == sorted(
["context-1m-2025-08-07", "interleaved-thinking-2025-05-14"]
["context-1m-2025-08-07", "computer-use-2025-01-24"]
)
def test_messages_transformation_anthropic_beta(self):
@ -98,7 +96,7 @@ class TestAnthropicBetaHeaderSupport:
headers = {"anthropic-beta": "context-1m-2025-08-07"}
result = config.transform_anthropic_messages_request(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
model="anthropic.claude-opus-4-5-20250514-v1:0",
messages=[{"role": "user", "content": "Test"}],
anthropic_messages_optional_request_params={"max_tokens": 100},
litellm_params={},
@ -125,7 +123,7 @@ class TestAnthropicBetaHeaderSupport:
]
result = config._transform_request_helper(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
model="anthropic.claude-opus-4-5-20250514-v1:0",
system_content_blocks=[],
optional_params={"tools": tools},
messages=[{"role": "user", "content": "Test"}],
@ -135,10 +133,8 @@ class TestAnthropicBetaHeaderSupport:
additional_fields = result["additionalModelRequestFields"]
betas = additional_fields["anthropic_beta"]
# Should contain user header plus computer-use beta for this model (Haiku 4.5 uses 2025-01-24)
# Should contain both user-provided and auto-added beta headers
assert "context-1m-2025-08-07" in betas
assert "computer-use-2024-10-22" in betas or "computer-use-2025-01-24" in betas
assert len(betas) == 2 # No duplicates
def test_no_anthropic_beta_headers(self):
"""Test that transformations work correctly when no anthropic_beta headers are provided."""
@ -158,21 +154,18 @@ class TestAnthropicBetaHeaderSupport:
def test_anthropic_beta_all_supported_features(self):
"""Test that all documented beta features are properly handled."""
# Only include beta headers that the centralized bedrock config supports
supported_features = [
"context-1m-2025-08-07",
"computer-use-2025-01-24",
"computer-use-2024-10-22",
"token-efficient-tools-2025-02-19",
"interleaved-thinking-2025-05-14",
"output-128k-2025-02-19",
"dev-full-thinking-2025-05-14",
"tool-search-tool-2025-10-19",
]
config = AmazonAnthropicClaudeConfig()
headers = {"anthropic-beta": ",".join(supported_features)}
result = config.transform_request(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
model="anthropic.claude-opus-4-5-20250514-v1:0",
messages=[{"role": "user", "content": "Test"}],
optional_params={},
litellm_params={},

View file

@ -2503,3 +2503,189 @@ async def test_post_call_success_hook_only_runs_output_scan():
mock_make.call_args.kwargs.get("logging_event_type")
== GuardrailEventHooks.post_call
)
@pytest.mark.asyncio
async def test__redact_pii_matches_with_null_list_fields():
"""Test that _redact_pii_matches handles None/null list fields without crashing.
Bedrock API can return null for fields like regexes, customWords, managedWordLists,
and piiEntities. The .get("key", []) pattern returns None (not []) when the key
exists with a null value, which previously caused 'NoneType' object is not iterable.
"""
# Real-world response from Bedrock where regexes is null
response_with_null_regexes = {
"action": "NONE",
"actionReason": "No action.",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": [
{
"action": "NONE",
"detected": True,
"match": "joebloggs@gmail.com",
"type": "EMAIL",
}
],
"regexes": None, # null from Bedrock API
},
"wordPolicy": None, # entire policy is null
"topicPolicy": None,
"contentPolicy": None,
"contextualGroundingPolicy": None,
}
],
}
# Should not raise any exception
redacted = _redact_pii_matches(response_with_null_regexes)
# PII entity match should be redacted
pii_entities = redacted["assessments"][0]["sensitiveInformationPolicy"][
"piiEntities"
]
assert pii_entities[0]["match"] == "[REDACTED]"
assert pii_entities[0]["type"] == "EMAIL"
# Test with null piiEntities and non-null regexes
response_with_null_pii = {
"action": "NONE",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": None, # null
"regexes": [
{
"name": "CUSTOM",
"match": "secret-pattern",
"action": "BLOCKED",
}
],
},
}
],
}
redacted = _redact_pii_matches(response_with_null_pii)
regexes = redacted["assessments"][0]["sensitiveInformationPolicy"]["regexes"]
assert regexes[0]["match"] == "[REDACTED]"
# Test with null customWords and managedWordLists in wordPolicy
response_with_null_word_lists = {
"action": "NONE",
"assessments": [
{
"wordPolicy": {
"customWords": None, # null
"managedWordLists": None, # null
},
}
],
}
# Should not raise any exception
redacted = _redact_pii_matches(response_with_null_word_lists)
assert redacted["assessments"][0]["wordPolicy"]["customWords"] is None
assert redacted["assessments"][0]["wordPolicy"]["managedWordLists"] is None
@pytest.mark.asyncio
async def test_should_raise_guardrail_blocked_exception_with_null_list_fields():
"""Test that _should_raise_guardrail_blocked_exception handles None/null list fields.
Same issue as _redact_pii_matches: Bedrock API returns null for list fields
like topics, filters, customWords, etc. which causes iteration over None.
"""
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
# Response where all policy sub-lists are null
response_all_null_lists = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": None, # null
},
"contentPolicy": {
"filters": None, # null
},
"wordPolicy": {
"customWords": None, # null
"managedWordLists": None, # null
},
"sensitiveInformationPolicy": {
"piiEntities": None, # null
"regexes": None, # null
},
"contextualGroundingPolicy": {
"filters": None, # null
},
}
],
}
# Should not raise any exception and should return False
# (no BLOCKED actions found since all lists are null)
result = guardrail._should_raise_guardrail_blocked_exception(
response_all_null_lists
)
assert result is False
# Response with a mix of null lists and a BLOCKED action
response_mixed_null_with_blocked = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": None, # null - should not crash
},
"contentPolicy": {
"filters": [
{
"type": "HATE",
"confidence": "HIGH",
"action": "BLOCKED",
}
],
},
"wordPolicy": {
"customWords": None, # null
"managedWordLists": None, # null
},
"sensitiveInformationPolicy": {
"piiEntities": None, # null
"regexes": None, # null
},
"contextualGroundingPolicy": None, # entire policy is null
}
],
}
# Should return True because there's a BLOCKED content filter
result = guardrail._should_raise_guardrail_blocked_exception(
response_mixed_null_with_blocked
)
assert result is True
# Response with null lists but action is not GUARDRAIL_INTERVENED
response_no_intervention = {
"action": "NONE",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": None,
"regexes": None,
},
}
],
}
result = guardrail._should_raise_guardrail_blocked_exception(
response_no_intervention
)
assert result is False