diff --git a/litellm/constants.py b/litellm/constants.py index ae98b37d6e6..b2f3e8c35d5 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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", diff --git a/litellm/llms/bedrock/README.md b/litellm/llms/bedrock/README.md new file mode 100644 index 00000000000..2963eaa54d1 --- /dev/null +++ b/litellm/llms/bedrock/README.md @@ -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) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 90dfa13e938..bd6c5b6e7cd 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -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 diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index a13336b6c88..8a76f972f56 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -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), diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 42c3bd517a9..6eb2ec0d4b8 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -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), diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 765c419479e..a4c0eae292c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -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") diff --git a/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py b/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py index a20ec94a99d..e2c9b3f0add 100644 --- a/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py +++ b/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py @@ -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={}, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 71178c4826c..8cff61ad628 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -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